stacker.news/wallets/lib/validate.js
2025-09-23 10:09:24 +02:00

201 lines
7.2 KiB
JavaScript

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 { decodeBech32 as clinkDecodeBech32, OfferPriceType } from '@shocknet/clink-sdk'
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)
return {
walletPubkey: url.host,
secret: url.searchParams.get('secret'),
relayUrls: url.searchParams.getAll('relay'),
lud16: url.searchParams.get('lud16')
}
}
export const clinkValidator = (type) =>
string()
.matches(new RegExp(`^${type}1`), { message: `must start with ${type}1` })
.matches(/^(noffer|ndebit)1[02-9ac-hj-np-z]+$/, { message: 'invalid bech32 encoding' })
.test({
name: 'decode',
test: (v, context) => {
let decoded
try {
decoded = clinkDecodeBech32(v)
} catch (e) {
return context.createError({ message: `failed to decode bech32: ${e.message}` })
}
if (decoded.type !== type) {
return context.createError({ message: `must be ${type}` })
}
const { data } = decoded
if (!data) return context.createError({ message: 'no data' })
if (type === 'noffer' && data.priceType && data.priceType !== OfferPriceType.Spontaneous) {
return context.createError({ message: 'offer must be for spontaneous payments' })
}
return true
}
})
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 = ({ min = 12, max = 24 } = {}) =>
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 < min) {
return context.createError({ message: `needs at least ${min} words` })
}
if (words.length > max) {
return context.createError({ message: `max ${max} 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`)