24 changed files with 1066 additions and 221 deletions
@ -0,0 +1,36 @@ |
|||||||
|
import { describe, expect, it } from 'vitest' |
||||||
|
import { |
||||||
|
extractAboutCoinPaymentMethods, |
||||||
|
parseAboutCoinLabelPaymentLines |
||||||
|
} from './payto-about-coin-lines' |
||||||
|
import { extractKind0PaymentMethodsFromProfileJson } from './payto-kind0-import' |
||||||
|
|
||||||
|
const XMR_ADDR = |
||||||
|
'84mAJEgdihyRHkz8fGeuqgbQ19SuGeFWbhokJG2uMNMwTkDyoyQ3H7BijQNwSriSp9hHfaRGZYpCuKvHJwTer8av845U9py' |
||||||
|
|
||||||
|
describe('parseAboutCoinLabelPaymentLines', () => { |
||||||
|
it('parses XMR: line in profile about', () => { |
||||||
|
const about = `https://pubky.app/profile/foo\n\nXMR: ${XMR_ADDR}\n\nSimpleX: https://smp17.simplex.im/a#test` |
||||||
|
const matches = parseAboutCoinLabelPaymentLines(about) |
||||||
|
expect(matches).toHaveLength(1) |
||||||
|
expect(matches[0].paytoType).toBe('monero') |
||||||
|
expect(matches[0].authority).toBe(XMR_ADDR) |
||||||
|
expect(matches[0].payto).toBe(`payto://monero/${encodeURIComponent(XMR_ADDR)}`) |
||||||
|
}) |
||||||
|
}) |
||||||
|
|
||||||
|
describe('extractKind0PaymentMethodsFromProfileJson about', () => { |
||||||
|
it('imports monero from about coin line', () => { |
||||||
|
const methods = extractKind0PaymentMethodsFromProfileJson({ |
||||||
|
about: `XMR: ${XMR_ADDR}` |
||||||
|
}) |
||||||
|
expect(methods.some((m) => m.type === 'monero' && m.authority === XMR_ADDR)).toBe(true) |
||||||
|
}) |
||||||
|
}) |
||||||
|
|
||||||
|
describe('extractAboutCoinPaymentMethods', () => { |
||||||
|
it('dedupes repeated labels', () => { |
||||||
|
const methods = extractAboutCoinPaymentMethods(`XMR: ${XMR_ADDR}\nXMR: ${XMR_ADDR}`) |
||||||
|
expect(methods).toHaveLength(1) |
||||||
|
}) |
||||||
|
}) |
||||||
@ -0,0 +1,142 @@ |
|||||||
|
/** |
||||||
|
* `XMR: 4abc…` / `BTC: bc1…` lines in kind 0 `about` (catalog-driven labels). |
||||||
|
*/ |
||||||
|
|
||||||
|
import paytoTypesCatalog from '@/data/payto-types.json' |
||||||
|
import { buildPaytoUri } from '@/lib/payto' |
||||||
|
import { getCanonicalPaytoType, getPaytoEditorTypeLabel, isKnownPaytoType } from '@/lib/payto-registry' |
||||||
|
import type { Kind0ImportedPaymentMethod } from '@/lib/payto-kind0-import' |
||||||
|
|
||||||
|
type PaytoAboutCoinCatalog = { |
||||||
|
kind0CryptocurrencyAddresses?: Record<string, string> |
||||||
|
aliases?: Record<string, string> |
||||||
|
} |
||||||
|
|
||||||
|
const catalog = paytoTypesCatalog as PaytoAboutCoinCatalog |
||||||
|
|
||||||
|
function escapeRegExp(s: string): string { |
||||||
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') |
||||||
|
} |
||||||
|
|
||||||
|
function mapCoinLabelToPaytoType(label: string): string | null { |
||||||
|
const k = label.trim().toLowerCase() |
||||||
|
if (!k) return null |
||||||
|
const fromCrypto = catalog.kind0CryptocurrencyAddresses?.[k] |
||||||
|
if (fromCrypto) return getCanonicalPaytoType(fromCrypto) |
||||||
|
const canonical = getCanonicalPaytoType(k) |
||||||
|
return isKnownPaytoType(canonical) ? canonical : null |
||||||
|
} |
||||||
|
|
||||||
|
function buildAboutCoinLabelAlternation(): string { |
||||||
|
const labels = new Set<string>() |
||||||
|
const crypto = catalog.kind0CryptocurrencyAddresses ?? {} |
||||||
|
for (const key of Object.keys(crypto)) { |
||||||
|
labels.add(key) |
||||||
|
labels.add(key.toUpperCase()) |
||||||
|
} |
||||||
|
for (const [alias, canonical] of Object.entries(catalog.aliases ?? {})) { |
||||||
|
if (crypto[alias] || Object.values(crypto).includes(canonical)) { |
||||||
|
labels.add(alias) |
||||||
|
labels.add(alias.toUpperCase()) |
||||||
|
} |
||||||
|
} |
||||||
|
return [...labels] |
||||||
|
.sort((a, b) => b.length - a.length) |
||||||
|
.map(escapeRegExp) |
||||||
|
.join('|') |
||||||
|
} |
||||||
|
|
||||||
|
let aboutCoinLineRegex: RegExp | null = null |
||||||
|
|
||||||
|
function getAboutCoinLineRegex(): RegExp | null { |
||||||
|
if (aboutCoinLineRegex) return aboutCoinLineRegex |
||||||
|
const alternation = buildAboutCoinLabelAlternation() |
||||||
|
if (!alternation) return null |
||||||
|
aboutCoinLineRegex = new RegExp( |
||||||
|
`(?:^|[\\n\\r])\\s*(${alternation})\\s*:\\s*([^\\s\\n]+)`, |
||||||
|
'gi' |
||||||
|
) |
||||||
|
return aboutCoinLineRegex |
||||||
|
} |
||||||
|
|
||||||
|
export type AboutCoinLineMatch = { |
||||||
|
coinLabel: string |
||||||
|
authority: string |
||||||
|
paytoType: string |
||||||
|
payto: string |
||||||
|
displayType: string |
||||||
|
/** Full matched segment including label and address (for content replacement). */ |
||||||
|
raw: string |
||||||
|
} |
||||||
|
|
||||||
|
export function parseAboutCoinLabelPaymentLines(about: string): AboutCoinLineMatch[] { |
||||||
|
const text = about?.trim() |
||||||
|
if (!text) return [] |
||||||
|
const regex = getAboutCoinLineRegex() |
||||||
|
if (!regex) return [] |
||||||
|
|
||||||
|
const seen = new Set<string>() |
||||||
|
const out: AboutCoinLineMatch[] = [] |
||||||
|
regex.lastIndex = 0 |
||||||
|
|
||||||
|
for (const match of text.matchAll(regex)) { |
||||||
|
const coinLabel = match[1] ?? '' |
||||||
|
const authority = (match[2] ?? '').trim() |
||||||
|
if (!authority) continue |
||||||
|
const paytoType = mapCoinLabelToPaytoType(coinLabel) |
||||||
|
if (!paytoType) continue |
||||||
|
const dedupe = `${paytoType}:${authority.toLowerCase()}` |
||||||
|
if (seen.has(dedupe)) continue |
||||||
|
seen.add(dedupe) |
||||||
|
out.push({ |
||||||
|
coinLabel, |
||||||
|
authority, |
||||||
|
paytoType, |
||||||
|
payto: buildPaytoUri(paytoType, authority), |
||||||
|
displayType: getPaytoEditorTypeLabel(paytoType), |
||||||
|
raw: match[0] |
||||||
|
}) |
||||||
|
} |
||||||
|
return out |
||||||
|
} |
||||||
|
|
||||||
|
export function extractAboutCoinPaymentMethods(about: string): Kind0ImportedPaymentMethod[] { |
||||||
|
return parseAboutCoinLabelPaymentLines(about).map((m) => ({ |
||||||
|
type: m.paytoType, |
||||||
|
authority: m.authority, |
||||||
|
payto: m.payto, |
||||||
|
displayType: m.displayType |
||||||
|
})) |
||||||
|
} |
||||||
|
|
||||||
|
/** Split profile about text into plain segments and payto URIs for {@link parseContent}. */ |
||||||
|
export function parseAboutContentWithCoinPayto(content: string): import('@/lib/content-parser').TEmbeddedNode[] { |
||||||
|
const text = content |
||||||
|
if (!text) return [{ type: 'text', data: '' }] |
||||||
|
|
||||||
|
const regex = getAboutCoinLineRegex() |
||||||
|
if (!regex) return [{ type: 'text', data: text }] |
||||||
|
|
||||||
|
const result: import('@/lib/content-parser').TEmbeddedNode[] = [] |
||||||
|
let lastIndex = 0 |
||||||
|
regex.lastIndex = 0 |
||||||
|
|
||||||
|
for (const match of text.matchAll(regex)) { |
||||||
|
const matchStart = match.index ?? 0 |
||||||
|
const coinLabel = match[1] ?? '' |
||||||
|
const authority = (match[2] ?? '').trim() |
||||||
|
const paytoType = mapCoinLabelToPaytoType(coinLabel) |
||||||
|
if (!authority || !paytoType) continue |
||||||
|
|
||||||
|
if (matchStart > lastIndex) { |
||||||
|
result.push({ type: 'text', data: text.slice(lastIndex, matchStart) }) |
||||||
|
} |
||||||
|
result.push({ type: 'payto', data: buildPaytoUri(paytoType, authority) }) |
||||||
|
lastIndex = matchStart + match[0].length |
||||||
|
} |
||||||
|
|
||||||
|
if (lastIndex < text.length) { |
||||||
|
result.push({ type: 'text', data: text.slice(lastIndex) }) |
||||||
|
} |
||||||
|
return result.length > 0 ? result : [{ type: 'text', data: text }] |
||||||
|
} |
||||||
@ -0,0 +1,37 @@ |
|||||||
|
import { describe, expect, it } from 'vitest' |
||||||
|
import { extractKind0PaymentMethodsFromProfileJson } from './payto-kind0-import' |
||||||
|
|
||||||
|
describe('extractKind0PaymentMethodsFromProfileJson', () => { |
||||||
|
it('imports Garnet cryptocurrency_addresses.monero', () => { |
||||||
|
const addr = '4AdUndXHHZ6cfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgvYatVKtQNjUoNcknXV85jSp3wjUGpHbWfnqPm4WjwFGtW' |
||||||
|
const methods = extractKind0PaymentMethodsFromProfileJson({ |
||||||
|
cryptocurrency_addresses: { monero: addr } |
||||||
|
}) |
||||||
|
expect(methods).toHaveLength(1) |
||||||
|
expect(methods[0].type).toBe('monero') |
||||||
|
expect(methods[0].authority).toBe(addr) |
||||||
|
expect(methods[0].payto).toBe(`payto://monero/${addr}`) |
||||||
|
}) |
||||||
|
|
||||||
|
it('maps xmr alias to monero', () => { |
||||||
|
const methods = extractKind0PaymentMethodsFromProfileJson({ |
||||||
|
cryptocurrency_addresses: { xmr: '4abc' } |
||||||
|
}) |
||||||
|
expect(methods[0]?.type).toBe('monero') |
||||||
|
}) |
||||||
|
|
||||||
|
it('reads bare root monero key', () => { |
||||||
|
const methods = extractKind0PaymentMethodsFromProfileJson({ |
||||||
|
monero: '4root' |
||||||
|
}) |
||||||
|
expect(methods[0]?.authority).toBe('4root') |
||||||
|
}) |
||||||
|
|
||||||
|
it('dedupes cryptocurrency_addresses and root field', () => { |
||||||
|
const methods = extractKind0PaymentMethodsFromProfileJson({ |
||||||
|
monero: '4same', |
||||||
|
cryptocurrency_addresses: { monero: '4same' } |
||||||
|
}) |
||||||
|
expect(methods).toHaveLength(1) |
||||||
|
}) |
||||||
|
}) |
||||||
@ -0,0 +1,94 @@ |
|||||||
|
/** |
||||||
|
* Kind 0 JSON payment hints (incl. Garnet {@code cryptocurrency_addresses}) → payto methods. |
||||||
|
*/ |
||||||
|
|
||||||
|
import paytoTypesCatalog from '@/data/payto-types.json' |
||||||
|
import { buildPaytoUri } from '@/lib/payto' |
||||||
|
import { extractAboutCoinPaymentMethods } from '@/lib/payto-about-coin-lines' |
||||||
|
import { getCanonicalPaytoType, getPaytoEditorTypeLabel, isKnownPaytoType } from '@/lib/payto-registry' |
||||||
|
|
||||||
|
export type Kind0ImportedPaymentMethod = { |
||||||
|
type: string |
||||||
|
authority: string |
||||||
|
payto: string |
||||||
|
displayType: string |
||||||
|
} |
||||||
|
|
||||||
|
type PaytoKind0ImportCatalog = { |
||||||
|
kind0CryptocurrencyAddresses?: Record<string, string> |
||||||
|
kind0RootPaymentFields?: Record<string, string> |
||||||
|
} |
||||||
|
|
||||||
|
const importCatalog = paytoTypesCatalog as PaytoKind0ImportCatalog |
||||||
|
|
||||||
|
function mapExternalKeyToPaytoType(externalKey: string): string | null { |
||||||
|
const k = externalKey.trim().toLowerCase() |
||||||
|
if (!k) return null |
||||||
|
const fromCrypto = importCatalog.kind0CryptocurrencyAddresses?.[k] |
||||||
|
if (fromCrypto) return getCanonicalPaytoType(fromCrypto) |
||||||
|
const fromRoot = importCatalog.kind0RootPaymentFields?.[k] |
||||||
|
if (fromRoot) return getCanonicalPaytoType(fromRoot) |
||||||
|
const canonical = getCanonicalPaytoType(k) |
||||||
|
return isKnownPaytoType(canonical) ? canonical : null |
||||||
|
} |
||||||
|
|
||||||
|
function readStringAddress(value: unknown): string | null { |
||||||
|
if (typeof value !== 'string') return null |
||||||
|
const s = value.trim() |
||||||
|
return s.length > 0 ? s : null |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Extract payto-shaped methods from parsed kind 0 metadata JSON. |
||||||
|
* Supports Garnet `cryptocurrency_addresses` and legacy top-level coin keys. |
||||||
|
*/ |
||||||
|
export function extractKind0PaymentMethodsFromProfileJson( |
||||||
|
profileJson: unknown |
||||||
|
): Kind0ImportedPaymentMethod[] { |
||||||
|
if (!profileJson || typeof profileJson !== 'object' || Array.isArray(profileJson)) { |
||||||
|
return [] |
||||||
|
} |
||||||
|
|
||||||
|
const obj = profileJson as Record<string, unknown> |
||||||
|
const seen = new Set<string>() |
||||||
|
const out: Kind0ImportedPaymentMethod[] = [] |
||||||
|
|
||||||
|
const push = (externalKey: string, address: string) => { |
||||||
|
const paytoType = mapExternalKeyToPaytoType(externalKey) |
||||||
|
if (!paytoType) return |
||||||
|
const authority = address.trim() |
||||||
|
if (!authority) return |
||||||
|
const dedupe = `${paytoType}:${authority.toLowerCase()}` |
||||||
|
if (seen.has(dedupe)) return |
||||||
|
seen.add(dedupe) |
||||||
|
out.push({ |
||||||
|
type: paytoType, |
||||||
|
authority, |
||||||
|
payto: buildPaytoUri(paytoType, authority), |
||||||
|
displayType: getPaytoEditorTypeLabel(paytoType) |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
const cryptoBlock = obj.cryptocurrency_addresses |
||||||
|
if (cryptoBlock && typeof cryptoBlock === 'object' && !Array.isArray(cryptoBlock)) { |
||||||
|
for (const [key, value] of Object.entries(cryptoBlock as Record<string, unknown>)) { |
||||||
|
const addr = readStringAddress(value) |
||||||
|
if (addr) push(key, addr) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const rootKeys = importCatalog.kind0RootPaymentFields ?? {} |
||||||
|
for (const externalKey of Object.keys(rootKeys)) { |
||||||
|
const addr = readStringAddress(obj[externalKey]) |
||||||
|
if (addr) push(externalKey, addr) |
||||||
|
} |
||||||
|
|
||||||
|
const about = obj.about |
||||||
|
if (typeof about === 'string' && about.trim()) { |
||||||
|
for (const m of extractAboutCoinPaymentMethods(about)) { |
||||||
|
push(m.type, m.authority) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return out |
||||||
|
} |
||||||
@ -0,0 +1,121 @@ |
|||||||
|
import { describe, expect, it } from 'vitest' |
||||||
|
import { getPaytoProfileUrl } from '@/lib/payto-registry' |
||||||
|
import { |
||||||
|
filterPaytoPaymentOpenHandlersForDevice, |
||||||
|
filterWalletOpenActionsForDevice, |
||||||
|
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 bolt12 deep link from offer string', () => { |
||||||
|
const offer = 'lno1pg257enxv4ezqcneype82um50ynhxgrwdajx283qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36ryg488qwlrnzyjczs' |
||||||
|
const actions = getPaytoWalletOpenActions('bolt12', offer) |
||||||
|
expect(actions).toHaveLength(1) |
||||||
|
expect(actions[0].href).toBe(`phoenix:pay?uri=bolt12:${offer}`) |
||||||
|
expect(actions[0].mobileOnly).toBe(true) |
||||||
|
}) |
||||||
|
|
||||||
|
it('includes Phoenix on mobile only', () => { |
||||||
|
const handlers = getPaytoPaymentOpenHandlers('lightning', 'user@example.com') |
||||||
|
const phoenix = handlers.find((h) => h.openTargetName === 'Phoenix') |
||||||
|
expect(phoenix?.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 }) |
||||||
|
} |
||||||
|
}) |
||||||
|
}) |
||||||
|
|
||||||
|
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,236 @@ |
|||||||
|
/** |
||||||
|
* 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 PaytoTypeRecordWallet = { |
||||||
|
label?: string |
||||||
|
profileUrlTemplate?: string |
||||||
|
walletOpen?: PaytoWalletOpenRow |
||||||
|
} |
||||||
|
|
||||||
|
/** 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 = { |
||||||
|
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}` |
||||||
|
} |
||||||
|
|
||||||
|
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 href = substituteAuthority( |
||||||
|
app.uriTemplate.replace(/\{coinScheme\}/g, coinScheme), |
||||||
|
auth |
||||||
|
) |
||||||
|
return href |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 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) |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Named app/site open targets for PaytoDialog (https + walletApps only). |
||||||
|
* Native coin schemes (monero:, bitcoin:, …) are omitted — users copy the payto URI instead. |
||||||
|
*/ |
||||||
|
export function getPaytoPaymentOpenHandlers(type: string, authority: string): PaytoPaymentOpenHandler[] { |
||||||
|
const canonical = getCanonicalPaytoType(type) |
||||||
|
const record = getPaytoTypeRecord(canonical) |
||||||
|
const auth = trimAuthority(authority) |
||||||
|
if (!auth || !record) return [] |
||||||
|
|
||||||
|
const handlers: PaytoPaymentOpenHandler[] = [] |
||||||
|
const seen = new Set<string>() |
||||||
|
|
||||||
|
const add = ( |
||||||
|
id: string, |
||||||
|
openTargetName: string, |
||||||
|
href: string | null | undefined, |
||||||
|
mobileOnly?: boolean |
||||||
|
) => { |
||||||
|
if (!href || seen.has(href)) return |
||||||
|
seen.add(href) |
||||||
|
handlers.push({ |
||||||
|
id, |
||||||
|
openTargetName, |
||||||
|
href, |
||||||
|
isHttp: isPaytoHttpOpenUrl(href), |
||||||
|
mobileOnly |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
if (canonical === 'paypal') { |
||||||
|
add('paypal', 'PayPal', resolvePaypalPaymentUrl(auth)) |
||||||
|
return handlers |
||||||
|
} |
||||||
|
|
||||||
|
if (record.profileUrlTemplate) { |
||||||
|
add( |
||||||
|
`${canonical}-web`, |
||||||
|
record.label ?? canonical, |
||||||
|
substituteAuthority(record.profileUrlTemplate, encodeURIComponent(auth)) |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
for (const app of getPaytoWalletOpenActions(type, auth)) { |
||||||
|
add(app.id, app.label, app.href, app.mobileOnly) |
||||||
|
} |
||||||
|
|
||||||
|
return handlers |
||||||
|
} |
||||||
|
|
||||||
|
export function filterPaytoPaymentOpenHandlersForDevice( |
||||||
|
handlers: PaytoPaymentOpenHandler[] |
||||||
|
): PaytoPaymentOpenHandler[] { |
||||||
|
if (isLikelyMobileWalletUserAgent()) return handlers |
||||||
|
return handlers.filter((h) => !h.mobileOnly) |
||||||
|
} |
||||||
Loading…
Reference in new issue