import bip39Words from '@/lib/bip39-words' import { decodeRune } from '@/lib/cln' import { B64_URL_REGEX } from '@/lib/format' import { isInvoicableMacaroon, isInvoiceMacaroon } from '@/lib/macaroon' import { NOSTR_PUBKEY_HEX } from '@/lib/nostr' import { TOR_REGEXP } from '@/lib/url' import { lightningAddressValidator } from '@/lib/validate' import { string, array } from 'yup' export const externalLightningAddressValidator = lightningAddressValidator .test({ name: 'address', test: addr => !addr.toLowerCase().endsWith('@stacker.news'), message: 'lightning address must be external' }) export const nwcUrlValidator = () => string() .url() .test({ test: (url, context) => { if (!url) return true // run validation in sequence to control order of errors // inspired by https://github.com/jquense/yup/issues/851#issuecomment-1049705180 try { string().matches(/^nostr\+?walletconnect:\/\//, { message: 'must start with nostr+walletconnect://' }).validateSync(url) let relayUrls, walletPubkey, secret try { ({ relayUrls, walletPubkey, secret } = parseNwcUrl(url)) } catch { // invalid URL error. handle as if pubkey validation failed to not confuse user. throw new Error('pubkey must be 64 hex chars') } string().required('pubkey required').trim().matches(NOSTR_PUBKEY_HEX, 'pubkey must be 64 hex chars').validateSync(walletPubkey) array().of( string().required('relay url required').trim().wss('relay must use wss://') ).min(1, 'at least one relay required').validateSync(relayUrls) string().required('secret required').trim().matches(/^[0-9a-fA-F]{64}$/, 'secret must be 64 hex chars').validateSync(secret) } catch (err) { return context.createError({ message: err.message }) } return true } }) export function parseNwcUrl (walletConnectUrl) { if (!walletConnectUrl) return {} walletConnectUrl = walletConnectUrl .replace('nostrwalletconnect://', 'http://') .replace('nostr+walletconnect://', 'http://') // makes it possible to parse with URL in the different environments (browser/node/...) // XXX There is a bug in parsing since we use the URL constructor for parsing: // A wallet pubkey matching /^[0-9a-fA-F]{64}$/ might not be a valid hostname. // Example: 11111111111 (10 1's) is a valid hostname (gets parsed as IPv4) but 111111111111 (11 1's) is not. // See https://stackoverflow.com/questions/56804936/how-does-only-numbers-in-url-resolve-to-a-domain // However, this seems to only get triggered if a wallet pubkey only contains digits so this is pretty improbable. const url = new URL(walletConnectUrl) const params = {} params.walletPubkey = url.host const secret = url.searchParams.get('secret') const relayUrls = url.searchParams.getAll('relay') if (secret) { params.secret = secret } if (relayUrls) { params.relayUrls = relayUrls } return params } export const socketValidator = (msg = 'invalid socket') => string() .test({ name: 'socket', message: msg, test: value => { try { const url = new URL(`http://${value}`) return url.hostname && url.port && !url.username && !url.password && (!url.pathname || url.pathname === '/') && !url.search && !url.hash } catch (e) { return false } }, exclusive: false }) export const runeValidator = ({ method }) => string() .matches(B64_URL_REGEX, { message: 'invalid rune' }) .test({ name: 'rune', test: (v, context) => { const decoded = decodeRune(v) if (!decoded) return context.createError({ message: 'invalid rune' }) if (decoded.restrictions.length === 0) { return context.createError({ message: `rune must be restricted to method=${method}` }) } if (decoded.restrictions.length !== 1 || decoded.restrictions[0].alternatives.length !== 1) { return context.createError({ message: `rune must be restricted to method=${method} only` }) } if (decoded.restrictions[0].alternatives[0] !== `method=${method}`) { return context.createError({ message: `rune must be restricted to method=${method} only` }) } return true } }) export const invoiceMacaroonValidator = () => string() .hexOrBase64() .test({ name: 'macaroon', test: v => isInvoiceMacaroon(v) || isInvoicableMacaroon(v), message: 'not an invoice macaroon or an invoicable macaroon' }) export const bip39Validator = () => string() .test({ name: 'bip39', test: async (value, context) => { const words = value ? value.trim().split(/[\s]+/) : [] for (const w of words) { try { await string().oneOf(bip39Words).validate(w) } catch { return context.createError({ message: `'${w}' is not a valid pairing phrase word` }) } } if (words.length < 2) { return context.createError({ message: 'needs at least two words' }) } if (words.length > 10) { return context.createError({ message: 'max 10 words' }) } return true } }) export const certValidator = () => string().hexOrBase64() export const urlValidator = (...args) => process.env.NODE_ENV === 'development' ? string() .or([ string().matches(/^(http:\/\/)?localhost:\d+$/), string().url() ], 'invalid url') .trim() : string().url().trim() .test(async (url, context) => { if (args.includes('tor') && TOR_REGEXP.test(url)) { // allow HTTP and HTTPS over Tor if (!/^https?:\/\//.test(url)) { return context.createError({ message: 'http or https required' }) } return true } if (args.includes('clearnet')) { try { // force HTTPS over clearnet await string().https().validate(url) } catch (err) { return context.createError({ message: err.message }) } } return true }) export const hexValidator = (length) => string().hex().length(length, `must be exactly ${length} hex chars`)