Browse Source

wallet handlers

imwald
Silberengel 4 weeks ago
parent
commit
fddb722fa0
  1. 113
      src/components/PaytoDialog/index.tsx
  2. 24
      src/data/payto-types.json
  3. 4
      src/lib/payto-registry.ts
  4. 79
      src/lib/payto-wallet-open.test.ts
  5. 224
      src/lib/payto-wallet-open.ts
  6. 6
      src/lib/payto.ts

113
src/components/PaytoDialog/index.tsx

@ -7,7 +7,15 @@ import {
DialogTitle DialogTitle
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Copy, ExternalLink, Wallet, Zap } from 'lucide-react' import {
Select,
SelectContent,
SelectItem,
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 { useTranslation } from 'react-i18next'
import { closeModal } from '@getalby/bitcoin-connect-react' import { closeModal } from '@getalby/bitcoin-connect-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
@ -16,7 +24,6 @@ import { releaseBodyScrollLocks } from '@/lib/react-remove-scroll-body-cleanup'
import { import {
filterPaytoPaymentOpenHandlersForDevice, filterPaytoPaymentOpenHandlersForDevice,
getPaytoPaymentOpenHandlers, getPaytoPaymentOpenHandlers,
getPhoenixPaymentOpenHandler,
getPaytoTypeInfo getPaytoTypeInfo
} from '@/lib/payto' } from '@/lib/payto'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@ -50,10 +57,12 @@ export default function PaytoDialog({
const label = info?.label ?? type const label = info?.label ?? type
const isLightning = type.toLowerCase() === 'lightning' const isLightning = type.toLowerCase() === 'lightning'
const [bolt11Invoice, setBolt11Invoice] = useState<string | null>(null) const [bolt11Invoice, setBolt11Invoice] = useState<string | null>(null)
const [selectedOpenHandlerId, setSelectedOpenHandlerId] = useState('')
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
setBolt11Invoice(null) setBolt11Invoice(null)
setSelectedOpenHandlerId('')
closeModal() closeModal()
releaseBodyScrollLocks() releaseBodyScrollLocks()
} }
@ -64,14 +73,42 @@ export default function PaytoDialog({
onOpenChange(false) onOpenChange(false)
}, [onOpenChange]) }, [onOpenChange])
const openHandlers = useMemo(() => { const openHandlers = useMemo(
const handlers = getPaytoPaymentOpenHandlers(type, authority) () =>
if (isLightning && bolt11Invoice) { filterPaytoPaymentOpenHandlersForDevice(
const phoenix = getPhoenixPaymentOpenHandler('lightning', bolt11Invoice) getPaytoPaymentOpenHandlers(type, authority, { bolt11Invoice })
if (phoenix) handlers.push(phoenix) ),
[type, authority, bolt11Invoice]
)
useEffect(() => {
if (openHandlers.length === 0) {
setSelectedOpenHandlerId('')
return
}
setSelectedOpenHandlerId((prev) =>
openHandlers.some((h) => h.id === prev) ? prev : openHandlers[0].id
)
}, [openHandlers])
const selectedOpenHandler = useMemo(
() =>
openHandlers.find((h) => h.id === selectedOpenHandlerId) ??
openHandlers[0] ??
null,
[openHandlers, selectedOpenHandlerId]
)
const openSelectedHandler = useCallback(
(handler: PaytoPaymentOpenHandler) => {
if (handler.isHttp) {
window.open(handler.href, '_blank', 'noopener,noreferrer')
return
} }
return filterPaytoPaymentOpenHandlersForDevice(handlers) window.location.assign(handler.href)
}, [type, authority, isLightning, bolt11Invoice]) },
[]
)
const handleCopy = (text: string, copyLabel?: string) => { const handleCopy = (text: string, copyLabel?: string) => {
navigator.clipboard.writeText(text) navigator.clipboard.writeText(text)
@ -166,31 +203,49 @@ export default function PaytoDialog({
<p className="text-sm font-semibold uppercase tracking-wider text-muted-foreground sm:text-base"> <p className="text-sm font-semibold uppercase tracking-wider text-muted-foreground sm:text-base">
{t('Open with')} {t('Open with')}
</p> </p>
<div className="grid min-w-0 grid-cols-1 gap-2 sm:grid-cols-2"> <div className="flex min-w-0 items-stretch gap-2">
<Select
value={selectedOpenHandlerId}
onValueChange={setSelectedOpenHandlerId}
>
<SelectTrigger
className="h-11 min-w-0 flex-1 text-base"
aria-label={t('Open with')}
>
<SelectValue
placeholder={t('Choose app', { defaultValue: 'Choose app' })}
/>
</SelectTrigger>
<SelectContent>
{openHandlers.map((handler) => ( {openHandlers.map((handler) => (
<SelectItem key={handler.id} value={handler.id} className="text-base">
{handler.openTargetName}
</SelectItem>
))}
</SelectContent>
</Select>
<Button <Button
key={handler.id} type="button"
variant="outline" variant="outline"
className="h-11 w-full min-w-0 justify-start gap-2 px-3 text-base" size="icon"
asChild className="h-11 w-11 shrink-0"
> disabled={!selectedOpenHandler}
<a title={
href={handler.href} selectedOpenHandler
className="flex min-w-0 items-center" ? t('Open in {{name}}', { name: selectedOpenHandler.openTargetName })
{...(handler.isHttp : undefined
? { target: '_blank', rel: 'noopener noreferrer' } }
: {})} aria-label={
onClick={(e) => e.stopPropagation()} selectedOpenHandler
? t('Open in {{name}}', { name: selectedOpenHandler.openTargetName })
: t('Open', { defaultValue: 'Open' })
}
onClick={() => {
if (selectedOpenHandler) openSelectedHandler(selectedOpenHandler)
}}
> >
{handler.isHttp ? ( <ArrowRight className="size-5" aria-hidden />
<ExternalLink className="size-5 shrink-0" />
) : (
<Wallet className="size-5 shrink-0" />
)}
<span className="truncate">{t('Open in {{name}}', { name: handler.openTargetName })}</span>
</a>
</Button> </Button>
))}
</div> </div>
</div> </div>
)} )}

24
src/data/payto-types.json

@ -3,6 +3,13 @@
"logoAssetsDir": "src/assets/payto_logos", "logoAssetsDir": "src/assets/payto_logos",
"logoAssetPathNote": "Repo-relative path to the logo file. The app bundles these via Vite and exposes them under /assets/ at runtime (see getPaytoLogoPath)." "logoAssetPathNote": "Repo-relative path to the logo file. The app bundles these via Vite and exposes them under /assets/ at runtime (see getPaytoLogoPath)."
}, },
"_openWith": {
"bolt11Invoice": {
"paytoType": "lightning",
"coinScheme": "lightning",
"walletApps": ["phoenix", "zeus", "bluewallet"]
}
},
"walletApps": { "walletApps": {
"cakewallet": { "cakewallet": {
"label": "Cake Wallet", "label": "Cake Wallet",
@ -13,6 +20,16 @@
"label": "Phoenix", "label": "Phoenix",
"mobileOnly": true, "mobileOnly": true,
"uriTemplate": "phoenix:{coinScheme}:{authority}" "uriTemplate": "phoenix:{coinScheme}:{authority}"
},
"zeus": {
"label": "Zeus",
"mobileOnly": true,
"uriTemplate": "zeusln:lightning:{authority}"
},
"bluewallet": {
"label": "BlueWallet",
"mobileOnly": true,
"uriTemplate": "bluewallet:lightning:{authority}"
} }
}, },
"kind0CryptocurrencyAddresses": { "kind0CryptocurrencyAddresses": {
@ -116,7 +133,7 @@
"walletOpen": { "walletOpen": {
"scheme": "bolt12", "scheme": "bolt12",
"requirePrefix": "lno1", "requirePrefix": "lno1",
"walletApps": ["phoenix"] "walletApps": ["phoenix", "zeus"]
}, },
"authority": { "authority": {
"placeholder": "lno1…", "placeholder": "lno1…",
@ -130,7 +147,7 @@
"walletOpen": { "walletOpen": {
"scheme": "lightning", "scheme": "lightning",
"requireAtSign": true, "requireAtSign": true,
"walletApps": ["phoenix"] "walletApps": ["phoenix", "zeus"]
}, },
"authority": { "authority": {
"placeholder": "user@example.com", "placeholder": "user@example.com",
@ -179,7 +196,7 @@
"walletOpen": { "walletOpen": {
"scheme": "lightning", "scheme": "lightning",
"requireAtSign": true, "requireAtSign": true,
"walletApps": ["phoenix"] "deferWalletAppsUntilBolt11": true
}, },
"authority": { "authority": {
"placeholder": "user@getalby.com", "placeholder": "user@getalby.com",
@ -361,6 +378,7 @@
"symbol": "💙", "symbol": "💙",
"category": "fiat", "category": "fiat",
"logoAssetPath": "src/assets/payto_logos/paypal.webp", "logoAssetPath": "src/assets/payto_logos/paypal.webp",
"paymentOpen": "paypal",
"profileUrlTemplate": "https://paypal.me/{authority}", "profileUrlTemplate": "https://paypal.me/{authority}",
"authority": { "authority": {
"placeholder": "username (not email)", "placeholder": "username (not email)",

4
src/lib/payto-registry.ts

@ -23,6 +23,8 @@ export type PaytoWalletOpenRow = {
requireAtSign?: boolean requireAtSign?: boolean
requirePrefix?: string requirePrefix?: string
walletApps?: string[] walletApps?: string[]
/** When true, {@link walletApps} are hidden until PaytoDialog has a BOLT11 (see catalog `_openWith.bolt11Invoice`). */
deferWalletAppsUntilBolt11?: boolean
} }
export type PaytoTypeRecord = { export type PaytoTypeRecord = {
@ -32,6 +34,8 @@ export type PaytoTypeRecord = {
/** Repo-relative path, e.g. `src/assets/payto_logos/ethereum-eth-logo.svg`. */ /** Repo-relative path, e.g. `src/assets/payto_logos/ethereum-eth-logo.svg`. */
logoAssetPath?: string logoAssetPath?: string
profileUrlTemplate?: string profileUrlTemplate?: string
/** PaytoDialog “Open with” mode; `paypal` uses the PayPal URL resolver only. */
paymentOpen?: 'paypal'
/** Native wallet URI / app deep link (see {@link getPaytoPrimaryOpenUrl}). */ /** Native wallet URI / app deep link (see {@link getPaytoPrimaryOpenUrl}). */
walletOpen?: PaytoWalletOpenRow walletOpen?: PaytoWalletOpenRow
authority?: PaytoAuthorityHelp authority?: PaytoAuthorityHelp

79
src/lib/payto-wallet-open.test.ts

@ -1,9 +1,12 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { getPaytoProfileUrl } from '@/lib/payto-registry' import { getPaytoProfileUrl } from '@/lib/payto-registry'
import { import {
buildBlueWalletWalletHref,
buildPhoenixWalletHref, buildPhoenixWalletHref,
buildZeusWalletHref,
filterPaytoPaymentOpenHandlersForDevice, filterPaytoPaymentOpenHandlersForDevice,
filterWalletOpenActionsForDevice, filterWalletOpenActionsForDevice,
getLightningInvoiceWalletPaymentHandlers,
getPaytoPaymentOpenHandlers, getPaytoPaymentOpenHandlers,
getPaytoPrimaryOpenUrl, getPaytoPrimaryOpenUrl,
getPaytoWalletOpenActions, getPaytoWalletOpenActions,
@ -86,12 +89,13 @@ describe('getPaytoPaymentOpenHandlers', () => {
expect(cash[0].href).toBe('https://cash.app/%24cashtag') expect(cash[0].href).toBe('https://cash.app/%24cashtag')
}) })
it('builds Phoenix bolt12 deep link from offer string', () => { it('builds Phoenix and Zeus bolt12 deep links from offer string', () => {
const offer = 'lno1pg257enxv4ezqcneype82um50ynhxgrwdajx283qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36ryg488qwlrnzyjczs' const offer = 'lno1pg257enxv4ezqcneype82um50ynhxgrwdajx283qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36ryg488qwlrnzyjczs'
const actions = getPaytoWalletOpenActions('bolt12', offer) const actions = getPaytoWalletOpenActions('bolt12', offer)
expect(actions).toHaveLength(1) expect(actions).toHaveLength(2)
expect(actions[0].href).toBe(`phoenix:bolt12:${offer}`) expect(actions.map((a) => a.label).sort()).toEqual(['Phoenix', 'Zeus'])
expect(actions[0].mobileOnly).toBe(true) 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', () => { it('builds Phoenix lightning deep link without pay?uri query', () => {
@ -104,16 +108,21 @@ describe('getPaytoPaymentOpenHandlers', () => {
) )
}) })
it('omits Phoenix for lightning address until BOLT11 is supplied separately', () => { it('omits mobile lightning wallets for lightning address until BOLT11 is supplied separately', () => {
const handlers = getPaytoPaymentOpenHandlers('lightning', 'user@example.com') const handlers = getPaytoPaymentOpenHandlers('lightning', 'user@example.com')
expect(handlers.some((h) => h.openTargetName === 'Phoenix')).toBe(false) 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 on mobile only for bip353', () => { it('includes Phoenix and Zeus on mobile only for bip353', () => {
const handlers = getPaytoPaymentOpenHandlers('bip353', 'user@example.com') const handlers = getPaytoPaymentOpenHandlers('bip353', 'user@example.com')
const phoenix = handlers.find((h) => h.openTargetName === 'Phoenix') 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(phoenix?.href).toBe('phoenix:lightning:user@example.com')
expect(zeus?.href).toBe('zeusln:lightning:user@example.com')
expect(phoenix?.mobileOnly).toBe(true) expect(phoenix?.mobileOnly).toBe(true)
expect(zeus?.mobileOnly).toBe(true)
const prev = navigator.userAgent const prev = navigator.userAgent
Object.defineProperty(navigator, 'userAgent', { Object.defineProperty(navigator, 'userAgent', {
@ -128,6 +137,64 @@ describe('getPaytoPaymentOpenHandlers', () => {
Object.defineProperty(navigator, 'userAgent', { value: prev, configurable: true }) 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', () => { describe('isPaytoHttpOpenUrl', () => {

224
src/lib/payto-wallet-open.ts

@ -6,12 +6,23 @@ import paytoTypesCatalog from '@/data/payto-types.json'
import type { PaytoWalletOpenRow } from '@/lib/payto-registry' import type { PaytoWalletOpenRow } from '@/lib/payto-registry'
import { resolvePaypalPaymentUrl } from '@/lib/payto-paypal-url' import { resolvePaypalPaymentUrl } from '@/lib/payto-paypal-url'
type PaytoBolt11InvoiceOpenConfig = {
paytoType?: string
coinScheme?: string
walletApps: string[]
}
type PaytoTypeRecordWallet = { type PaytoTypeRecordWallet = {
label?: string label?: string
profileUrlTemplate?: string profileUrlTemplate?: string
paymentOpen?: 'paypal'
walletOpen?: PaytoWalletOpenRow walletOpen?: PaytoWalletOpenRow
} }
type PaytoWalletCatalogOpenWith = {
bolt11Invoice?: PaytoBolt11InvoiceOpenConfig
}
/** Labeled “open in …” action shown inside {@link PaytoDialog}. */ /** Labeled “open in …” action shown inside {@link PaytoDialog}. */
export type PaytoPaymentOpenHandler = { export type PaytoPaymentOpenHandler = {
id: string id: string
@ -38,6 +49,7 @@ type WalletAppRowJson = {
} }
type PaytoWalletCatalogJson = { type PaytoWalletCatalogJson = {
_openWith?: PaytoWalletCatalogOpenWith
aliases?: Record<string, string> aliases?: Record<string, string>
types: Record<string, PaytoTypeRecordWallet> types: Record<string, PaytoTypeRecordWallet>
walletApps?: Record<string, WalletAppRowJson> walletApps?: Record<string, WalletAppRowJson>
@ -110,6 +122,62 @@ export function buildPhoenixWalletHref(coinScheme: string, authority: string): s
return `phoenix:${scheme}:${payload}` 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( function resolveWalletAppUri(
appId: string, appId: string,
paytoType: string, paytoType: string,
@ -121,8 +189,9 @@ function resolveWalletAppUri(
const auth = trimAuthority(authority) const auth = trimAuthority(authority)
if (!auth) return null if (!auth) return null
const coinScheme = (row?.scheme ?? paytoType).toLowerCase() const coinScheme = (row?.scheme ?? paytoType).toLowerCase()
if (appId === 'phoenix') { const customBuild = WALLET_APP_BUILDERS[appId]
return buildPhoenixWalletHref(coinScheme, auth) if (customBuild) {
return customBuild(coinScheme, auth)
} }
const href = substituteAuthority( const href = substituteAuthority(
app.uriTemplate.replace(/\{coinScheme\}/g, coinScheme), app.uriTemplate.replace(/\{coinScheme\}/g, coinScheme),
@ -138,16 +207,92 @@ export function getPhoenixPaymentOpenHandler(
): PaytoPaymentOpenHandler | null { ): PaytoPaymentOpenHandler | null {
const href = buildPhoenixWalletHref(coinScheme, authority) const href = buildPhoenixWalletHref(coinScheme, authority)
if (!href) return null if (!href) return null
return { return paymentOpenHandlerFromHref('phoenix', coinScheme, href)
id: `phoenix-${coinScheme}`, }
openTargetName: walletCatalog.walletApps?.phoenix?.label ?? 'Phoenix',
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<string>()
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, href,
isHttp: false, isHttp: false,
mobileOnly: walletCatalog.walletApps?.phoenix?.mobileOnly !== false mobileOnly: app.mobileOnly !== false
})
} }
return handlers
} }
const PAYTO_TYPES_PHOENIX_REQUIRES_BOLT11 = new Set(['lightning']) /** 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<string>()
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). * Primary browser/OS URL for this payto target (wallet URI or https).
@ -213,56 +358,57 @@ export function filterWalletOpenActionsForDevice(
} }
/** /**
* Named app/site open targets for PaytoDialog (https + walletApps only). * 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. * Native coin schemes (monero:, bitcoin:, ) are omitted; users copy the payto URI instead.
*/ */
export function getPaytoPaymentOpenHandlers(type: string, authority: string): PaytoPaymentOpenHandler[] { export function getPaytoPaymentOpenHandlers(
type: string,
authority: string,
context?: PaytoPaymentOpenContext
): PaytoPaymentOpenHandler[] {
const canonical = getCanonicalPaytoType(type) const canonical = getCanonicalPaytoType(type)
const record = getPaytoTypeRecord(canonical) const record = getPaytoTypeRecord(canonical)
const auth = trimAuthority(authority) const auth = trimAuthority(authority)
if (!auth || !record) return [] if (!auth || !record) return []
const handlers: PaytoPaymentOpenHandler[] = [] const handlers: PaytoPaymentOpenHandler[] = []
const seen = new Set<string>()
const add = ( if (record.paymentOpen === 'paypal') {
id: string, const href = resolvePaypalPaymentUrl(auth)
openTargetName: string, if (href) {
href: string | null | undefined,
mobileOnly?: boolean
) => {
if (!href || seen.has(href)) return
seen.add(href)
handlers.push({ handlers.push({
id, id: 'paypal',
openTargetName, openTargetName: record.label ?? 'PayPal',
href, href,
isHttp: isPaytoHttpOpenUrl(href), isHttp: true,
mobileOnly mobileOnly: false
}) })
} }
return dedupePaymentOpenHandlers(handlers)
if (canonical === 'paypal') {
add('paypal', 'PayPal', resolvePaypalPaymentUrl(auth))
return handlers
} }
if (record.profileUrlTemplate) { const walletRow = record.walletOpen
add( if (walletRow?.deferWalletAppsUntilBolt11) {
`${canonical}-web`, if (context?.bolt11Invoice) {
record.label ?? canonical, handlers.push(...getBolt11InvoiceOpenHandlers(context.bolt11Invoice))
substituteAuthority(record.profileUrlTemplate, encodeURIComponent(auth))
)
} }
} else if (walletRow?.walletApps?.length) {
for (const app of getPaytoWalletOpenActions(type, auth)) { for (const action of getPaytoWalletOpenActions(type, auth)) {
if (app.label === 'Phoenix' && PAYTO_TYPES_PHOENIX_REQUIRES_BOLT11.has(canonical)) { handlers.push(walletActionToOpenHandler(action))
continue
} }
add(app.id, app.label, app.href, app.mobileOnly)
} }
return handlers 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( export function filterPaytoPaymentOpenHandlersForDevice(

6
src/lib/payto.ts

@ -28,8 +28,13 @@ export {
} from '@/lib/payto-registry' } from '@/lib/payto-registry'
export { export {
buildBlueWalletWalletHref,
buildPhoenixWalletHref, buildPhoenixWalletHref,
buildZeusWalletHref,
getBlueWalletPaymentOpenHandler,
getLightningInvoiceWalletPaymentHandlers,
getPhoenixPaymentOpenHandler, getPhoenixPaymentOpenHandler,
getZeusPaymentOpenHandler,
getPaytoPrimaryOpenUrl, getPaytoPrimaryOpenUrl,
getPaytoPaymentOpenHandlers, getPaytoPaymentOpenHandlers,
filterPaytoPaymentOpenHandlersForDevice, filterPaytoPaymentOpenHandlersForDevice,
@ -37,6 +42,7 @@ export {
filterWalletOpenActionsForDevice, filterWalletOpenActionsForDevice,
isPaytoHttpOpenUrl, isPaytoHttpOpenUrl,
isLikelyMobileWalletUserAgent, isLikelyMobileWalletUserAgent,
type PaytoPaymentOpenContext,
type PaytoPaymentOpenHandler, type PaytoPaymentOpenHandler,
type PaytoWalletOpenAction type PaytoWalletOpenAction
} from '@/lib/payto-wallet-open' } from '@/lib/payto-wallet-open'

Loading…
Cancel
Save