diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index 65f575cc..0778f39f 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -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 }) diff --git a/lib/validate.js b/lib/validate.js index ac74c94d..77615b86 100644 --- a/lib/validate.js +++ b/lib/validate.js @@ -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) { diff --git a/lib/wallet.js b/lib/wallet.js index bff1d915..1882e110 100644 --- a/lib/wallet.js +++ b/lib/wallet.js @@ -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 : {}) + }) +} diff --git a/pages/settings/wallets/[wallet].js b/pages/settings/wallets/[wallet].js index 0da09689..bd90d63c 100644 --- a/pages/settings/wallets/[wallet].js +++ b/pages/settings/wallets/[wallet].js @@ -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 ( @@ -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 }) : {}) - ) -} diff --git a/wallets/lnd/index.js b/wallets/lnd/index.js index 9d1dda4a..288250d4 100644 --- a/wallets/lnd/index.js +++ b/wallets/lnd/index.js @@ -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'