Generate validation schema for LND

This commit is contained in:
ekzyis 2024-07-17 02:37:32 +02:00
parent 3933a4f460
commit 587bfa34be
5 changed files with 132 additions and 130 deletions

View File

@ -13,18 +13,18 @@ import assertApiKeyNotPermitted from './apiKey'
import { bolt11Tags } from '@/lib/bolt11'
import { checkInvoice } from 'worker/wallet'
import walletDefs from 'wallets/server'
import { generateResolverName } from '@/lib/wallet'
import { generateResolverName, generateSchema } from '@/lib/wallet'
import { lnAddrOptions } from '@/lib/lnurl'
function injectResolvers (resolvers) {
console.group('injected GraphQL resolvers:')
for (const w of walletDefs) {
const { schema, walletType, walletField, testConnect } = w
const { walletType, walletField, testConnect } = w
const resolverName = generateResolverName(walletField)
console.log(resolverName)
resolvers.Mutation[resolverName] = async (parent, { settings, ...data }, { me, models }) => {
return await upsertWallet({
schema,
schema: generateSchema(w),
wallet: { field: walletField, type: walletType },
testConnect: (data) =>
testConnect(data, { me, models })

View File

@ -9,7 +9,6 @@ import { NOSTR_MAX_RELAY_NUM, NOSTR_PUBKEY_BECH32, NOSTR_PUBKEY_HEX } from './no
import { msatsToSats, numWithUnits, abbrNum, ensureB64, B64_URL_REGEX } from './format'
import * as usersFragments from '@/fragments/users'
import * as subsFragments from '@/fragments/subs'
import { isInvoicableMacaroon, isInvoiceMacaroon } from './macaroon'
import { datePivot } from './time'
import { decodeRune } from '@/lib/cln'
import bip39Words from './bip39-words'
@ -156,7 +155,7 @@ const lightningAddressValidator = process.env.NODE_ENV === 'development'
'address is no good')
: string().email('address is no good')
const hexOrBase64Validator = string().test({
export const hexOrBase64Validator = string().test({
name: 'hex-or-base64',
message: 'invalid encoding',
test: (val) => {
@ -311,20 +310,7 @@ export function lnAddrAutowithdrawSchema ({ me } = {}) {
test: addr => !addr.endsWith('@stacker.news'),
message: 'automated withdrawals must be external'
}),
...autowithdrawSchemaMembers({ me })
})
}
export function LNDAutowithdrawSchema ({ me } = {}) {
return object({
socket: string().socket().required('required'),
macaroon: hexOrBase64Validator.required('required').test({
name: 'macaroon',
test: v => isInvoiceMacaroon(v) || isInvoicableMacaroon(v),
message: 'not an invoice macaroon or an invoicable macaroon'
}),
cert: hexOrBase64Validator,
...autowithdrawSchemaMembers({ me })
...autowithdrawSchemaMembers
})
}
@ -350,16 +336,14 @@ export function CLNAutowithdrawSchema ({ me } = {}) {
}
}),
cert: hexOrBase64Validator,
...autowithdrawSchemaMembers({ me })
...autowithdrawSchemaMembers
})
}
export function autowithdrawSchemaMembers ({ me } = {}) {
return {
enabled: boolean(),
autoWithdrawThreshold: intValidator.required('required').min(0, 'must be at least 0').max(msatsToSats(BALANCE_LIMIT_MSATS), `must be at most ${abbrNum(msatsToSats(BALANCE_LIMIT_MSATS))}`),
autoWithdrawMaxFeePercent: floatValidator.required('required').min(0, 'must be at least 0').max(50, 'must not exceed 50')
}
export const autowithdrawSchemaMembers = {
enabled: boolean(),
autoWithdrawThreshold: intValidator.required('required').min(0, 'must be at least 0').max(msatsToSats(BALANCE_LIMIT_MSATS), `must be at most ${abbrNum(msatsToSats(BALANCE_LIMIT_MSATS))}`),
autoWithdrawMaxFeePercent: floatValidator.required('required').min(0, 'must be at least 0').max(50, 'must not exceed 50')
}
export function bountySchema (args) {

View File

@ -1,4 +1,109 @@
import { array, object, string } from 'yup'
import { autowithdrawSchemaMembers, hexOrBase64Validator } from '@/lib/validate'
import { TOR_REGEXP } from '@/lib/url'
export function generateResolverName (walletField) {
const capitalized = walletField[0].toUpperCase() + walletField.slice(1)
return `upsertWallet${capitalized}`
}
export function generateSchema (wallet) {
if (wallet.schema) return wallet.schema
const fieldValidator = (field) => {
if (!field.validate) {
// default validation
let validator = string()
if (!field.optional) validator = validator.required('required')
return validator
}
// complex validation
if (field.validate.schema) return field.validate.schema
const { type: validationType, words, min, max } = field.validate
let validator
const stringTypes = ['url', 'string']
if (stringTypes.includes(validationType)) {
validator = string()
if (field.validate.length) validator = validator.length(field.validate.length)
}
if (validationType === 'url') {
validator = process.env.NODE_ENV === 'development'
? validator
.or([string().matches(/^(http:\/\/)?localhost:\d+$/), string().url()], 'invalid url')
: validator
.url()
.test(async (url, context) => {
if (field.validate.torAllowed && TOR_REGEXP.test(url)) {
// allow HTTP and HTTPS over Tor
if (!/^https?:\/\//.test(url)) {
return context.createError({ message: 'http or https required' })
}
return true
}
try {
// force HTTPS over clearnet
await string().https().validate(url)
} catch (err) {
return context.createError({ message: err.message })
}
return true
})
}
if (words) {
validator = array()
.transform(function (value, originalValue) {
if (this.isType(value) && value !== null) {
return value
}
return originalValue ? originalValue.trim().split(/[\s]+/) : []
})
.test(async (values, context) => {
for (const v of values) {
try {
await string().oneOf(words).validate(v)
} catch {
return context.createError({ message: `'${v}' is not a valid ${field.label} word` })
}
}
return true
})
}
if (validationType === 'socket') validator = string().socket()
if (validationType === 'hexOrBase64') validator = hexOrBase64Validator
if (min !== undefined) validator = validator.min(min)
if (max !== undefined) validator = validator.max(max)
if (!field.optional) validator = validator.required('required')
if (field.validate.test) {
validator = validator.test({
name: field.name,
test: field.validate.test,
message: field.validate.message
})
}
return validator
}
return object({
...wallet.fields.reduce((acc, field) => {
return {
...acc,
[field.name]: fieldValidator(field)
}
}, {}),
...(wallet.walletType ? autowithdrawSchemaMembers : {})
})
}

View File

@ -10,10 +10,7 @@ import Info from '@/components/info'
import Text from '@/components/text'
import { AutowithdrawSettings } from '@/components/autowithdraw-shared'
import dynamic from 'next/dynamic'
import { array, object, string } from 'yup'
import { autowithdrawSchemaMembers } from '@/lib/validate'
import { useMe } from '@/components/me'
import { TOR_REGEXP } from '@/lib/url'
import { generateSchema } from '@/lib/wallet'
const WalletButtonBar = dynamic(() => import('@/components/wallet-buttonbar.js'), { ssr: false })
@ -24,7 +21,6 @@ export default function WalletSettings () {
const router = useRouter()
const { wallet: name } = router.query
const wallet = useWallet(name)
const me = useMe()
const initial = wallet.fields.reduce((acc, field) => {
// We still need to run over all wallet fields via reduce
@ -38,7 +34,7 @@ export default function WalletSettings () {
}
}, wallet.config)
const schema = generateSchema(wallet, { me })
const schema = generateSchema(wallet)
return (
<CenterLayout>
@ -137,95 +133,3 @@ function WalletFields ({ wallet: { config, fields } }) {
return null
})
}
function generateSchema (wallet, { me }) {
if (wallet.schema) return wallet.schema
const fieldValidator = (field) => {
if (!field.validate) {
// default validation
let validator = string()
if (!field.optional) validator = validator.required('required')
return validator
}
if (field.validate.schema) {
// complex validation
return field.validate.schema
}
const { type: validationType, words, min, max } = field.validate
let validator
const stringTypes = ['url', 'string']
if (stringTypes.includes(validationType)) {
validator = string()
if (field.validate.length) {
validator = validator.length(field.validate.length)
}
}
if (validationType === 'url') {
validator = process.env.NODE_ENV === 'development'
? validator
.or([string().matches(/^(http:\/\/)?localhost:\d+$/), string().url()], 'invalid url')
: validator
.url()
.test(async (url, context) => {
if (field.validate.torAllowed && TOR_REGEXP.test(url)) {
// allow HTTP and HTTPS over Tor
if (!/^https?:\/\//.test(url)) {
return context.createError({ message: 'http or https required' })
}
return true
}
try {
// force HTTPS over clearnet
await string().https().validate(url)
} catch (err) {
return context.createError({ message: err.message })
}
return true
})
}
if (words) {
validator = array()
.transform(function (value, originalValue) {
if (this.isType(value) && value !== null) {
return value
}
return originalValue ? originalValue.trim().split(/[\s]+/) : []
})
.test(async (values, context) => {
for (const v of values) {
try {
await string().oneOf(words).validate(v)
} catch {
return context.createError({ message: `'${v}' is not a valid ${field.label} word` })
}
}
return true
})
}
if (min !== undefined) validator = validator.min(min)
if (max !== undefined) validator = validator.max(max)
if (!field.optional) validator = validator.required('required')
return validator
}
return object(
wallet.fields.reduce((acc, field) => {
return {
...acc,
[field.name]: fieldValidator(field)
}
}, wallet.walletType ? autowithdrawSchemaMembers({ me }) : {})
)
}

View File

@ -1,4 +1,4 @@
import { LNDAutowithdrawSchema } from '@/lib/validate'
import { isInvoicableMacaroon, isInvoiceMacaroon } from '@/lib/macaroon'
export const name = 'lnd'
@ -9,7 +9,10 @@ export const fields = [
type: 'text',
placeholder: '55.5.555.55:10001',
hint: 'tor or clearnet',
clear: true
clear: true,
validate: {
type: 'socket'
}
},
{
name: 'macaroon',
@ -21,7 +24,12 @@ export const fields = [
type: 'text',
placeholder: 'AgEDbG5kAlgDChCn7YgfWX7uTkQQgXZ2uahNEgEwGhYKB2FkZHJlc3MSBHJlYWQSBXdyaXRlGhcKCGludm9pY2VzEgRyZWFkEgV3cml0ZRoPCgdvbmNoYWluEgRyZWFkAAAGIJkMBrrDV0npU90JV0TGNJPrqUD8m2QYoTDjolaL6eBs',
hint: 'hex or base64 encoded',
clear: true
clear: true,
validate: {
type: 'hexOrBase64',
test: v => isInvoiceMacaroon(v) || isInvoicableMacaroon(v),
message: 'not an invoice macaroon or an invoicable macaroon'
}
},
{
name: 'cert',
@ -30,7 +38,10 @@ export const fields = [
placeholder: 'LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNNVENDQWRpZ0F3SUJBZ0lRSHBFdFdrcGJwZHV4RVF2eVBPc3NWVEFLQmdncWhrak9QUVFEQWpBdk1SOHcKSFFZRFZRUUtFeFpzYm1RZ1lYVjBiMmRsYm1WeVlYUmxaQ0JqWlhKME1Rd3dDZ1lEVlFRREV3TmliMkl3SGhjTgpNalF3TVRBM01qQXhORE0wV2hjTk1qVXdNekF6TWpBeE5ETTBXakF2TVI4d0hRWURWUVFLRXhac2JtUWdZWFYwCmIyZGxibVZ5WVhSbFpDQmpaWEowTVF3d0NnWURWUVFERXdOaWIySXdXVEFUQmdjcWhrak9QUUlCQmdncWhrak8KUFFNQkJ3TkNBQVJUS3NMVk5oZnhqb1FLVDlkVVdDbzUzSmQwTnBuL1BtYi9LUE02M1JxbU52dFYvdFk4NjJJZwpSbE41cmNHRnBEajhUeFc2OVhIK0pTcHpjWDdlN3N0Um80SFZNSUhTTUE0R0ExVWREd0VCL3dRRUF3SUNwREFUCkJnTlZIU1VFRERBS0JnZ3JCZ0VGQlFjREFUQVBCZ05WSFJNQkFmOEVCVEFEQVFIL01CMEdBMVVkRGdRV0JCVDAKMnh3V25GeHRUNzI0MWxwZlNoNm9FWi9UMWpCN0JnTlZIUkVFZERCeWdnTmliMktDQ1d4dlkyRnNhRzl6ZElJRApZbTlpZ2d4d2IyeGhjaTF1TVMxaWIyS0NGR2h2YzNRdVpHOWphMlZ5TG1sdWRHVnlibUZzZ2dSMWJtbDRnZ3AxCmJtbDRjR0ZqYTJWMGdnZGlkV1pqYjI1dWh3Ui9BQUFCaHhBQUFBQUFBQUFBQUFBQUFBQUFBQUFCaHdTc0VnQUQKTUFvR0NDcUdTTTQ5QkFNQ0EwY0FNRVFDSUEwUTlkRXdoNXpPRnpwL3hYeHNpemh5SkxNVG5yazU1VWx1NHJPRwo4WW52QWlBVGt4U3p3Y3hZZnFscGx0UlNIbmd0NUJFcDBzcXlHL05nenBzb2pmMGNqQT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K',
optional: 'optional if from [CA](https://en.wikipedia.org/wiki/Certificate_authority) (e.g. voltage)',
hint: 'hex or base64 encoded',
clear: true
clear: true,
validate: {
type: 'hexOrBase64'
}
}
]
@ -40,8 +51,6 @@ export const card = {
badges: ['receive only', 'non-custodial']
}
export const schema = LNDAutowithdrawSchema
export const walletType = 'LND'
export const walletField = 'walletLND'