You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
330 lines
10 KiB
330 lines
10 KiB
/** |
|
* 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) |
|
} |
|
const template = walletOpen?.walletAppUriTemplates?.[appId] ?? app.uriTemplate |
|
if (!template) return null |
|
const payload = |
|
coinScheme === 'lightning' || coinScheme === 'bolt12' |
|
? auth.replace(/^lightning:/i, '') |
|
: auth |
|
return substituteAuthority(template.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 payto / wallet URL (https in a new tab, coin schemes via assign). */ |
|
export function openPaytoResolvedUrl(url: string): void { |
|
if (isPaytoHttpOpenUrl(url)) { |
|
window.open(url, '_blank', 'noopener,noreferrer') |
|
return |
|
} |
|
window.location.assign(url) |
|
} |
|
|
|
/** Open a resolved handler (new tab for https, assign for wallet schemes). */ |
|
export function openPaytoPaymentTarget(handler: PaytoPaymentOpenHandler): void { |
|
openPaytoResolvedUrl(handler.href) |
|
}
|
|
|