8 changed files with 728 additions and 714 deletions
@ -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) |
||||||
|
}) |
||||||
|
}) |
||||||
@ -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) |
||||||
|
} |
||||||
@ -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) |
|
||||||
}) |
|
||||||
}) |
|
||||||
@ -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) |
|
||||||
} |
|
||||||
Loading…
Reference in new issue