Browse Source

refactor

imwald
Silberengel 4 weeks ago
parent
commit
72876fb1a6
  1. 21
      src/components/PaytoDialog/index.tsx
  2. 84
      src/data/payto-types.json
  3. 86
      src/lib/payto-registry.ts
  4. 271
      src/lib/payto-targets.test.ts
  5. 324
      src/lib/payto-targets.ts
  6. 205
      src/lib/payto-wallet-open.test.ts
  7. 419
      src/lib/payto-wallet-open.ts
  8. 32
      src/lib/payto.ts

21
src/components/PaytoDialog/index.tsx

@ -14,7 +14,6 @@ import {
SelectTrigger, SelectTrigger,
SelectValue SelectValue
} from '@/components/ui/select' } from '@/components/ui/select'
import type { PaytoPaymentOpenHandler } from '@/lib/payto'
import { ArrowRight, Copy, Zap } from 'lucide-react' 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'
@ -23,8 +22,9 @@ import { toast } from 'sonner'
import { releaseBodyScrollLocks } from '@/lib/react-remove-scroll-body-cleanup' import { releaseBodyScrollLocks } from '@/lib/react-remove-scroll-body-cleanup'
import { import {
filterPaytoPaymentOpenHandlersForDevice, filterPaytoPaymentOpenHandlersForDevice,
getPaytoPaymentOpenHandlers, getPaytoTypeInfo,
getPaytoTypeInfo openPaytoPaymentTarget,
resolvePaytoPaymentOpenHandlers
} from '@/lib/payto' } from '@/lib/payto'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
@ -76,7 +76,7 @@ export default function PaytoDialog({
const openHandlers = useMemo( const openHandlers = useMemo(
() => () =>
filterPaytoPaymentOpenHandlersForDevice( filterPaytoPaymentOpenHandlersForDevice(
getPaytoPaymentOpenHandlers(type, authority, { bolt11Invoice }) resolvePaytoPaymentOpenHandlers(type, authority, { bolt11Invoice })
), ),
[type, authority, bolt11Invoice] [type, authority, bolt11Invoice]
) )
@ -99,17 +99,6 @@ export default function PaytoDialog({
[openHandlers, selectedOpenHandlerId] [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) => { const handleCopy = (text: string, copyLabel?: string) => {
navigator.clipboard.writeText(text) navigator.clipboard.writeText(text)
toast.success(copyLabel ? t('Copied {{label}} address', { label: copyLabel }) : t('Copied to clipboard')) 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' }) : t('Open', { defaultValue: 'Open' })
} }
onClick={() => { onClick={() => {
if (selectedOpenHandler) openSelectedHandler(selectedOpenHandler) if (selectedOpenHandler) openPaytoPaymentTarget(selectedOpenHandler)
}} }}
> >
<ArrowRight className="size-5" aria-hidden /> <ArrowRight className="size-5" aria-hidden />

84
src/data/payto-types.json

@ -7,7 +7,18 @@
"bolt11Invoice": { "bolt11Invoice": {
"paytoType": "lightning", "paytoType": "lightning",
"coinScheme": "lightning", "coinScheme": "lightning",
"walletApps": ["phoenix", "zeus", "bluewallet"] "walletApps": [
"phoenix",
"zeus",
"bluewallet",
"muun",
"walletofsatoshi",
"blink",
"breez",
"alby",
"bitkit",
"blockstream"
]
} }
}, },
"walletApps": { "walletApps": {
@ -19,17 +30,62 @@
"phoenix": { "phoenix": {
"label": "Phoenix", "label": "Phoenix",
"mobileOnly": true, "mobileOnly": true,
"uriTemplate": "phoenix:{coinScheme}:{authority}" "builder": "phoenix"
}, },
"zeus": { "zeus": {
"label": "Zeus", "label": "Zeus",
"mobileOnly": true, "mobileOnly": true,
"uriTemplate": "zeusln:lightning:{authority}" "builder": "zeus"
}, },
"bluewallet": { "bluewallet": {
"label": "BlueWallet", "label": "BlueWallet",
"mobileOnly": true, "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": { "kind0CryptocurrencyAddresses": {
@ -118,7 +174,7 @@
"logoAssetPath": "src/assets/payto_logos/Bitcoin.svg", "logoAssetPath": "src/assets/payto_logos/Bitcoin.svg",
"walletOpen": { "walletOpen": {
"scheme": "bitcoin", "scheme": "bitcoin",
"walletApps": ["cakewallet"] "walletApps": ["cakewallet", "ledger"]
}, },
"authority": { "authority": {
"placeholder": "bc1q…", "placeholder": "bc1q…",
@ -133,7 +189,7 @@
"walletOpen": { "walletOpen": {
"scheme": "bolt12", "scheme": "bolt12",
"requirePrefix": "lno1", "requirePrefix": "lno1",
"walletApps": ["phoenix", "zeus"] "walletApps": ["phoenix", "zeus", "blixt"]
}, },
"authority": { "authority": {
"placeholder": "lno1…", "placeholder": "lno1…",
@ -147,7 +203,7 @@
"walletOpen": { "walletOpen": {
"scheme": "lightning", "scheme": "lightning",
"requireAtSign": true, "requireAtSign": true,
"walletApps": ["phoenix", "zeus"] "walletApps": ["phoenix", "zeus", "muun", "blink", "walletofsatoshi", "breez", "alby", "bitkit"]
}, },
"authority": { "authority": {
"placeholder": "user@example.com", "placeholder": "user@example.com",
@ -162,7 +218,7 @@
"walletOpen": { "walletOpen": {
"scheme": "bitcoin", "scheme": "bitcoin",
"requirePrefix": "sp1", "requirePrefix": "sp1",
"walletApps": ["cakewallet"] "walletApps": ["cakewallet", "ledger"]
}, },
"authority": { "authority": {
"placeholder": "sp1…", "placeholder": "sp1…",
@ -210,7 +266,7 @@
"logoAssetPath": "src/assets/payto_logos/ethereum-eth-logo.svg", "logoAssetPath": "src/assets/payto_logos/ethereum-eth-logo.svg",
"walletOpen": { "walletOpen": {
"scheme": "ethereum", "scheme": "ethereum",
"walletApps": ["cakewallet"] "walletApps": ["cakewallet", "ledger"]
}, },
"authority": { "authority": {
"placeholder": "0x…", "placeholder": "0x…",
@ -224,7 +280,7 @@
"logoAssetPath": "src/assets/payto_logos/Monero.png", "logoAssetPath": "src/assets/payto_logos/Monero.png",
"walletOpen": { "walletOpen": {
"scheme": "monero", "scheme": "monero",
"walletApps": ["cakewallet"] "walletApps": ["cakewallet", "ledger"]
}, },
"authority": { "authority": {
"placeholder": "4… or 8…", "placeholder": "4… or 8…",
@ -284,7 +340,7 @@
"logoAssetPath": "src/assets/payto_logos/bitcoin-cash-bch-logo.svg", "logoAssetPath": "src/assets/payto_logos/bitcoin-cash-bch-logo.svg",
"walletOpen": { "walletOpen": {
"scheme": "bitcoincash", "scheme": "bitcoincash",
"walletApps": ["cakewallet"] "walletApps": ["cakewallet", "ledger"]
}, },
"authority": { "authority": {
"placeholder": "bitcoincash:… or q…", "placeholder": "bitcoincash:… or q…",
@ -298,7 +354,7 @@
"logoAssetPath": "src/assets/payto_logos/dogecoin-doge-logo.svg", "logoAssetPath": "src/assets/payto_logos/dogecoin-doge-logo.svg",
"walletOpen": { "walletOpen": {
"scheme": "dogecoin", "scheme": "dogecoin",
"walletApps": ["cakewallet"] "walletApps": ["cakewallet", "ledger"]
}, },
"authority": { "authority": {
"placeholder": "D…", "placeholder": "D…",
@ -312,7 +368,7 @@
"logoAssetPath": "src/assets/payto_logos/Litecoin.png", "logoAssetPath": "src/assets/payto_logos/Litecoin.png",
"walletOpen": { "walletOpen": {
"scheme": "litecoin", "scheme": "litecoin",
"walletApps": ["cakewallet"] "walletApps": ["cakewallet", "ledger"]
}, },
"authority": { "authority": {
"placeholder": "ltc1q… or L… or M…", "placeholder": "ltc1q… or L… or M…",
@ -366,7 +422,7 @@
"logoAssetPath": "src/assets/payto_logos/solana.png", "logoAssetPath": "src/assets/payto_logos/solana.png",
"walletOpen": { "walletOpen": {
"scheme": "solana", "scheme": "solana",
"walletApps": ["cakewallet"] "walletApps": ["cakewallet", "ledger"]
}, },
"authority": { "authority": {
"placeholder": "Base58 pubkey…", "placeholder": "Base58 pubkey…",

86
src/lib/payto-registry.ts

@ -1,11 +1,10 @@
/** /**
* Loads payto type metadata from {@link ../data/payto-types.json}. * Payto type metadata from {@link ../data/payto-types.json}.
* Edit that JSON to add types, editor order, hints, logos, and profile URL templates. * URI / Open with resolution lives in {@link ./payto-targets}.
*/ */
import paytoTypesCatalog from '@/data/payto-types.json' import paytoTypesCatalog from '@/data/payto-types.json'
import { resolvePaytoLogoAssetPath } from '@/lib/payto-logos' import { resolvePaytoLogoAssetPath } from '@/lib/payto-logos'
import { getPaytoPrimaryOpenUrl } from '@/lib/payto-wallet-open'
import { resolvePaypalPaymentUrl } from '@/lib/payto-paypal-url' import { resolvePaypalPaymentUrl } from '@/lib/payto-paypal-url'
export type PaytoCategory = 'bitcoin' | 'bitcoin-layer' | 'crypto' | 'stablecoin' | 'fiat' | 'tip' export type PaytoCategory = 'bitcoin' | 'bitcoin-layer' | 'crypto' | 'stablecoin' | 'fiat' | 'tip'
@ -15,6 +14,17 @@ export type PaytoAuthorityHelp = {
hint: string 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 = { export type PaytoWalletOpenRow = {
scheme?: string scheme?: string
style?: 'path' | 'query' style?: 'path' | 'query'
@ -23,45 +33,52 @@ 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`). */ /** When true, {@link walletApps} are hidden until PaytoDialog supplies a BOLT11 (see `_openWith.bolt11Invoice`). */
deferWalletAppsUntilBolt11?: boolean deferWalletAppsUntilBolt11?: boolean
} }
export type PaytoBolt11InvoiceOpenConfig = {
paytoType?: string
coinScheme?: string
walletApps: string[]
}
export type PaytoOpenWithConfig = {
bolt11Invoice?: PaytoBolt11InvoiceOpenConfig
}
export type PaytoTypeRecord = { export type PaytoTypeRecord = {
label: string label: string
symbol?: string symbol?: string
category: PaytoCategory category: PaytoCategory
/** 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. */ /** PaytoDialog “Open with”: `paypal` uses the PayPal URL resolver only. */
paymentOpen?: 'paypal' paymentOpen?: 'paypal'
/** Native wallet URI / app deep link (see {@link getPaytoPrimaryOpenUrl}). */
walletOpen?: PaytoWalletOpenRow walletOpen?: PaytoWalletOpenRow
authority?: PaytoAuthorityHelp authority?: PaytoAuthorityHelp
} }
type PaytoTypesCatalogJson = { export type PaytoTypesCatalog = {
_openWith?: PaytoOpenWithConfig
walletApps?: Record<string, PaytoWalletAppRecord>
editorOrder: string[] editorOrder: string[]
genericAuthorityHelp: PaytoAuthorityHelp genericAuthorityHelp: PaytoAuthorityHelp
aliases: Record<string, string> aliases: Record<string, string>
types: Record<string, PaytoTypeRecord> types: Record<string, PaytoTypeRecord>
kind0CryptocurrencyAddresses?: Record<string, string>
kind0RootPaymentFields?: Record<string, string>
} }
const catalog = paytoTypesCatalog as PaytoTypesCatalogJson const catalog = paytoTypesCatalog as PaytoTypesCatalog
export const PAYTO_EDITOR_TYPE_ORDER: readonly string[] = catalog.editorOrder 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__' export const PAYTO_EDITOR_OTHER_OPTION = '__other__'
const GENERIC_AUTHORITY_HELP: PaytoAuthorityHelp = catalog.genericAuthorityHelp const GENERIC_AUTHORITY_HELP: PaytoAuthorityHelp = catalog.genericAuthorityHelp
const PAYTO_TYPE_ALIASES: Record<string, string> = catalog.aliases const PAYTO_TYPE_ALIASES: Record<string, string> = catalog.aliases
const PAYTO_TYPES: Record<string, PaytoTypeRecord> = catalog.types const PAYTO_TYPES: Record<string, PaytoTypeRecord> = catalog.types
/** UI summary per canonical type (label, symbol, category). */
export const PAYTO_KNOWN_TYPES: Record< export const PAYTO_KNOWN_TYPES: Record<
string, string,
{ label: string; symbol?: string; category: PaytoCategory } { 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 { export function getCanonicalPaytoType(type: string): string {
const key = type.toLowerCase().trim() const key = type.toLowerCase().trim()
return PAYTO_TYPE_ALIASES[key] ?? key return PAYTO_TYPE_ALIASES[key] ?? key
@ -81,6 +102,16 @@ export function getPaytoTypeRecord(type: string): PaytoTypeRecord | undefined {
return PAYTO_TYPES[getCanonicalPaytoType(type)] 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 { export function getPaytoTypeInfo(type: string): (typeof PAYTO_KNOWN_TYPES)[string] | undefined {
return PAYTO_KNOWN_TYPES[getCanonicalPaytoType(type)] return PAYTO_KNOWN_TYPES[getCanonicalPaytoType(type)]
} }
@ -98,55 +129,38 @@ export function getPaytoEditorTypeLabel(type: string): string {
return getPaytoTypeInfo(type)?.label ?? getCanonicalPaytoType(type) 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 { export function isPaytoEditorCustomType(type: string): boolean {
const trimmed = type.trim() const trimmed = type.trim()
if (!trimmed || trimmed === PAYTO_EDITOR_OTHER_OPTION) return true if (!trimmed || trimmed === PAYTO_EDITOR_OTHER_OPTION) return true
return !isKnownPaytoType(trimmed) return !isKnownPaytoType(trimmed)
} }
/** Dropdown options: catalog presets plus “Other”. */
export function paytoEditorSelectTypes(): string[] { export function paytoEditorSelectTypes(): string[] {
return [...PAYTO_EDITOR_TYPE_ORDER, PAYTO_EDITOR_OTHER_OPTION] return [...PAYTO_EDITOR_TYPE_ORDER, PAYTO_EDITOR_OTHER_OPTION]
} }
/** Bundled asset URL for `<img src>` (resolved from catalog `logoAssetPath`). */
export function getPaytoLogoPath(type: string): string | null { export function getPaytoLogoPath(type: string): string | null {
return resolvePaytoLogoAssetPath(getPaytoTypeRecord(type)?.logoAssetPath) 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 { export function getPaytoLogoUrl(type: string): string | null {
return getPaytoLogoPath(type) 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 { export function getPaytoIconChar(type: string): string | null {
return getPaytoTypeRecord(type)?.symbol ?? 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 { export function isLightningPaytoType(type: string): boolean {
const canonical = getCanonicalPaytoType(type) const canonical = getCanonicalPaytoType(type)
return canonical === 'lightning' || canonical === 'bip353' 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 { export function isZappableLightningPaytoType(type: string): boolean {
return getCanonicalPaytoType(type) === 'lightning' 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())
}

271
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)
})
})

324
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<string, string>,
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<string, string> = {
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<string>()
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)
}

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

@ -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)
})
})

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

@ -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<string, string>
types: Record<string, PaytoTypeRecordWallet>
walletApps?: Record<string, WalletAppRowJson>
}
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<string, string>, 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<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,
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<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).
* 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)
}

32
src/lib/payto.ts

@ -1,6 +1,6 @@
/** /**
* payto: URI handling (RFC-8905 / NIP-A3) * payto: URI handling (RFC-8905 / NIP-A3) and payment targets.
* Type metadata lives in {@link ../data/payto-types.json} via {@link ./payto-registry}. * Type metadata: {@link ./payto-registry}. Open / wallet URIs: {@link ./payto-targets}.
*/ */
import { getCanonicalPaytoType } from '@/lib/payto-registry' import { getCanonicalPaytoType } from '@/lib/payto-registry'
@ -12,7 +12,6 @@ export {
getPaytoIconChar, getPaytoIconChar,
getPaytoLogoPath, getPaytoLogoPath,
getPaytoLogoUrl, getPaytoLogoUrl,
getPaytoProfileUrl,
getPaytoTypeInfo, getPaytoTypeInfo,
isKnownPaytoType, isKnownPaytoType,
isLightningPaytoType, isLightningPaytoType,
@ -28,24 +27,15 @@ export {
} from '@/lib/payto-registry' } from '@/lib/payto-registry'
export { export {
buildBlueWalletWalletHref,
buildPhoenixWalletHref,
buildZeusWalletHref,
getBlueWalletPaymentOpenHandler,
getLightningInvoiceWalletPaymentHandlers,
getPhoenixPaymentOpenHandler,
getZeusPaymentOpenHandler,
getPaytoPrimaryOpenUrl,
getPaytoPaymentOpenHandlers,
filterPaytoPaymentOpenHandlersForDevice, filterPaytoPaymentOpenHandlersForDevice,
getPaytoWalletOpenActions, openPaytoPaymentTarget,
filterWalletOpenActionsForDevice, resolvePaytoPaymentOpenHandlers,
resolvePaytoProfileUrl,
isPaytoHttpOpenUrl, isPaytoHttpOpenUrl,
isLikelyMobileWalletUserAgent, isLikelyMobileWalletUserAgent,
type PaytoPaymentOpenContext, type PaytoPaymentOpenContext,
type PaytoPaymentOpenHandler, type PaytoPaymentOpenHandler
type PaytoWalletOpenAction } from '@/lib/payto-targets'
} from '@/lib/payto-wallet-open'
export const PAYTO_URI_REGEX = /payto:\/\/([a-z0-9-]+)\/([^\s\]\)\<\"']+)/gi export const PAYTO_URI_REGEX = /payto:\/\/([a-z0-9-]+)\/([^\s\]\)\<\"']+)/gi
@ -55,9 +45,6 @@ export interface ParsedPayto {
raw: string raw: string
} }
/**
* Parse a payto URI into type and authority. Returns null if invalid.
*/
export function parsePaytoUri(uri: string): ParsedPayto | null { export function parsePaytoUri(uri: string): ParsedPayto | null {
const trimmed = uri.trim() const trimmed = uri.trim()
const m = /^payto:\/\/([a-z0-9-]+)\/(.+)$/i.exec(trimmed) 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 } return { type, authority, raw: trimmed }
} }
/**
* Build payto URI from type and authority.
*/
export function buildPaytoUri(type: string, authority: string): string { export function buildPaytoUri(type: string, authority: string): string {
const t = type.toLowerCase().replace(/[^a-z0-9-]/g, '') const t = type.toLowerCase().replace(/[^a-z0-9-]/g, '')
const a = encodeURIComponent(authority.trim()) const a = encodeURIComponent(authority.trim())
@ -90,6 +74,6 @@ export { extractKind0PaymentMethodsFromProfileJson, type Kind0ImportedPaymentMet
export { export {
extractAboutCoinPaymentMethods, extractAboutCoinPaymentMethods,
parseAboutCoinLabelPaymentLines, parseAboutContentWithCoinPayto,
type AboutCoinLineMatch type AboutCoinLineMatch
} from '@/lib/payto-about-coin-lines' } from '@/lib/payto-about-coin-lines'

Loading…
Cancel
Save