refactor wallet validation

This commit is contained in:
k00b 2024-10-25 14:10:37 -05:00
parent 57603a936f
commit e96982c353
27 changed files with 557 additions and 500 deletions

View File

@ -1,4 +1,4 @@
import { inviteSchema, ssValidate } from '@/lib/validate'
import { inviteSchema, validateSchema } from '@/lib/validate'
import { msatsToSats } from '@/lib/format'
import assertApiKeyNotPermitted from './apiKey'
import { GqlAuthenticationError } from '@/lib/error'
@ -35,7 +35,7 @@ export default {
}
assertApiKeyNotPermitted({ me })
await ssValidate(inviteSchema, { gift, limit })
await validateSchema(inviteSchema, { gift, limit })
return await models.invite.create({
data: { gift, limit, userId: me.id }

View File

@ -13,7 +13,7 @@ import {
import { msatsToSats } from '@/lib/format'
import { parse } from 'tldts'
import uu from 'url-unshort'
import { actSchema, advSchema, bountySchema, commentSchema, discussionSchema, jobSchema, linkSchema, pollSchema, ssValidate } from '@/lib/validate'
import { actSchema, advSchema, bountySchema, commentSchema, discussionSchema, jobSchema, linkSchema, pollSchema, validateSchema } from '@/lib/validate'
import { defaultCommentSort, isJob, deleteItemByAuthor } from '@/lib/item'
import { datePivot, whenRange } from '@/lib/time'
import { uploadIdsFromText } from './upload'
@ -844,7 +844,7 @@ export default {
return await deleteItemByAuthor({ models, id, item: old })
},
upsertLink: async (parent, { id, ...item }, { me, models, lnd }) => {
await ssValidate(linkSchema, item, { models, me })
await validateSchema(linkSchema, item, { models, me })
if (id) {
return await updateItem(parent, { id, ...item }, { me, models, lnd })
@ -853,7 +853,7 @@ export default {
}
},
upsertDiscussion: async (parent, { id, ...item }, { me, models, lnd }) => {
await ssValidate(discussionSchema, item, { models, me })
await validateSchema(discussionSchema, item, { models, me })
if (id) {
return await updateItem(parent, { id, ...item }, { me, models, lnd })
@ -862,7 +862,7 @@ export default {
}
},
upsertBounty: async (parent, { id, ...item }, { me, models, lnd }) => {
await ssValidate(bountySchema, item, { models, me })
await validateSchema(bountySchema, item, { models, me })
if (id) {
return await updateItem(parent, { id, ...item }, { me, models, lnd })
@ -879,7 +879,7 @@ export default {
})
: 0
await ssValidate(pollSchema, item, { models, me, numExistingChoices })
await validateSchema(pollSchema, item, { models, me, numExistingChoices })
if (id) {
return await updateItem(parent, { id, ...item }, { me, models, lnd })
@ -894,7 +894,7 @@ export default {
}
item.location = item.location?.toLowerCase() === 'remote' ? undefined : item.location
await ssValidate(jobSchema, item, { models })
await validateSchema(jobSchema, item, { models })
if (item.logo !== undefined) {
item.uploadId = item.logo
delete item.logo
@ -907,7 +907,7 @@ export default {
}
},
upsertComment: async (parent, { id, ...item }, { me, models, lnd }) => {
await ssValidate(commentSchema, item)
await validateSchema(commentSchema, item)
if (id) {
return await updateItem(parent, { id, ...item }, { me, models, lnd })
@ -937,7 +937,7 @@ export default {
},
act: async (parent, { id, sats, act = 'TIP' }, { me, models, lnd, headers }) => {
assertApiKeyNotPermitted({ me })
await ssValidate(actSchema, { sats, act })
await validateSchema(actSchema, { sats, act })
await assertGofacYourself({ models, headers })
const [item] = await models.$queryRawUnsafe(`
@ -1369,7 +1369,7 @@ export const updateItem = async (parent, { sub: subName, forward, hash, hmac, ..
}
// in case they lied about their existing boost
await ssValidate(advSchema, { boost: item.boost }, { models, me, existingBoost: old.boost })
await validateSchema(advSchema, { boost: item.boost }, { models, me, existingBoost: old.boost })
const user = await models.user.findUnique({ where: { id: meId } })

View File

@ -1,7 +1,7 @@
import { decodeCursor, LIMIT, nextNoteCursorEncoded } from '@/lib/cursor'
import { getItem, filterClause, whereClause, muteClause, activeOrMine } from './item'
import { getInvoice, getWithdrawl } from './wallet'
import { pushSubscriptionSchema, ssValidate } from '@/lib/validate'
import { pushSubscriptionSchema, validateSchema } from '@/lib/validate'
import { replyToSubscription } from '@/lib/webPush'
import { getSub } from './sub'
import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
@ -375,7 +375,7 @@ export default {
throw new GqlAuthenticationError()
}
await ssValidate(pushSubscriptionSchema, { endpoint, p256dh, auth })
await validateSchema(pushSubscriptionSchema, { endpoint, p256dh, auth })
let dbPushSubscription
if (oldEndpoint) {

View File

@ -1,4 +1,4 @@
import { amountSchema, ssValidate } from '@/lib/validate'
import { amountSchema, validateSchema } from '@/lib/validate'
import { getAd, getItem } from './item'
import { topUsers } from './user'
import performPaidAction from '../paidAction'
@ -171,7 +171,7 @@ export default {
},
Mutation: {
donateToRewards: async (parent, { sats }, { me, models, lnd }) => {
await ssValidate(amountSchema, { amount: sats })
await validateSchema(amountSchema, { amount: sats })
return await performPaidAction('DONATE', { sats }, { me, models, lnd })
}

View File

@ -1,5 +1,5 @@
import { whenRange } from '@/lib/time'
import { ssValidate, territorySchema } from '@/lib/validate'
import { validateSchema, territorySchema } from '@/lib/validate'
import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
import { viewGroup } from './growth'
import { notifyTerritoryTransfer } from '@/lib/webPush'
@ -157,7 +157,7 @@ export default {
throw new GqlAuthenticationError()
}
await ssValidate(territorySchema, data, { models, me, sub: { name: data.oldName } })
await validateSchema(territorySchema, data, { models, me, sub: { name: data.oldName } })
if (data.oldName) {
return await updateSub(parent, data, { me, models, lnd })
@ -260,7 +260,7 @@ export default {
const { name } = data
await ssValidate(territorySchema, data, { models, me })
await validateSchema(territorySchema, data, { models, me })
const oldSub = await models.sub.findUnique({ where: { name } })
if (!oldSub) {

View File

@ -2,7 +2,7 @@ import { readFile } from 'fs/promises'
import { join, resolve } from 'path'
import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
import { msatsToSats } from '@/lib/format'
import { bioSchema, emailSchema, settingsSchema, ssValidate, userSchema } from '@/lib/validate'
import { bioSchema, emailSchema, settingsSchema, validateSchema, userSchema } from '@/lib/validate'
import { getItem, updateItem, filterClause, createItem, whereClause, muteClause, activeOrMine } from './item'
import { USER_ID, RESERVED_MAX_USER_ID, SN_NO_REWARDS_IDS, INVOICE_ACTION_NOTIFICATION_TYPES } from '@/lib/constants'
import { viewGroup } from './growth'
@ -632,7 +632,7 @@ export default {
throw new GqlAuthenticationError()
}
await ssValidate(userSchema, data, { models })
await validateSchema(userSchema, data, { models })
try {
await models.user.update({ where: { id: me.id }, data })
@ -649,7 +649,7 @@ export default {
throw new GqlAuthenticationError()
}
await ssValidate(settingsSchema, { nostrRelays, ...data })
await validateSchema(settingsSchema, { nostrRelays, ...data })
if (nostrRelays?.length) {
const connectOrCreate = []
@ -696,7 +696,7 @@ export default {
throw new GqlAuthenticationError()
}
await ssValidate(bioSchema, { text })
await validateSchema(bioSchema, { text })
const user = await models.user.findUnique({ where: { id: me.id } })
@ -770,7 +770,7 @@ export default {
}
assertApiKeyNotPermitted({ me })
await ssValidate(emailSchema, { email })
await validateSchema(emailSchema, { email })
try {
await models.user.update({

View File

@ -12,7 +12,7 @@ import {
ANON_BALANCE_LIMIT_MSATS, ANON_INV_PENDING_LIMIT, USER_ID, BALANCE_LIMIT_MSATS,
INVOICE_RETENTION_DAYS, INV_PENDING_LIMIT, USER_IDS_BALANCE_NO_LIMIT, LND_PATHFINDING_TIMEOUT_MS
} from '@/lib/constants'
import { amountSchema, ssValidate, withdrawlSchema, lnAddrSchema, walletValidate } from '@/lib/validate'
import { amountSchema, validateSchema, withdrawlSchema, lnAddrSchema } from '@/lib/validate'
import { datePivot } from '@/lib/time'
import assertGofacYourself from './ofac'
import assertApiKeyNotPermitted from './apiKey'
@ -23,6 +23,7 @@ import { generateResolverName, generateTypeDefName } from '@/wallets/graphql'
import { lnAddrOptions } from '@/lib/lnurl'
import { GqlAuthenticationError, GqlAuthorizationError, GqlInputError } from '@/lib/error'
import { getNodeSockets, getOurPubkey } from '../lnd'
import validateWallet from '@/wallets/validate'
function injectResolvers (resolvers) {
console.group('injected GraphQL resolvers:')
@ -32,7 +33,7 @@ function injectResolvers (resolvers) {
resolvers.Mutation[resolverName] = async (parent, { settings, validateLightning, vaultEntries, ...data }, { me, models }) => {
// allow transformation of the data on validation (this is optional ... won't do anything if not implemented)
// TODO: our validation should be improved
const validData = await walletValidate(walletDef, { ...data, ...settings, vaultEntries })
const validData = await validateWallet(walletDef, { ...data, ...settings, vaultEntries })
if (validData) {
Object.keys(validData).filter(key => key in data).forEach(key => { data[key] = validData[key] })
Object.keys(validData).filter(key => key in settings).forEach(key => { settings[key] = validData[key] })
@ -437,7 +438,7 @@ const resolvers = {
},
Mutation: {
createInvoice: async (parent, { amount, hodlInvoice = false, expireSecs = 3600 }, { me, models, lnd, headers }) => {
await ssValidate(amountSchema, { amount })
await validateSchema(amountSchema, { amount })
await assertGofacYourself({ models, headers })
let expirePivot = { seconds: expireSecs }
@ -783,7 +784,7 @@ async function upsertWallet (
export async function createWithdrawal (parent, { invoice, maxFee }, { me, models, lnd, headers, walletId = null }) {
assertApiKeyNotPermitted({ me })
await ssValidate(withdrawlSchema, { invoice, maxFee })
await validateSchema(withdrawlSchema, { invoice, maxFee })
await assertGofacYourself({ models, headers })
// remove 'lightning:' prefix if present
@ -867,7 +868,7 @@ export async function fetchLnAddrInvoice (
me, models, lnd, autoWithdraw = false
}) {
const options = await lnAddrOptions(addr)
await ssValidate(lnAddrSchema, { addr, amount, maxFee, comment, ...payer }, options)
await validateSchema(lnAddrSchema, { addr, amount, maxFee, comment, ...payer }, options)
if (payer) {
payer = {

View File

@ -1,11 +1,9 @@
import { InputGroup } from 'react-bootstrap'
import { Checkbox, Input } from './form'
import { Input } from './form'
import { useMe } from './me'
import { useEffect, useState } from 'react'
import { isNumber } from '@/lib/validate'
import { useIsClient } from './use-client'
import Link from 'next/link'
import { isConfigured } from '@/wallets/common'
function autoWithdrawThreshold ({ me }) {
return isNumber(me?.privates?.autoWithdrawThreshold) ? me?.privates?.autoWithdrawThreshold : 10000
@ -19,7 +17,7 @@ export function autowithdrawInitial ({ me }) {
}
}
export function AutowithdrawSettings ({ wallet }) {
export function AutowithdrawSettings () {
const { me } = useMe()
const threshold = autoWithdrawThreshold({ me })
@ -29,16 +27,8 @@ export function AutowithdrawSettings ({ wallet }) {
setSendThreshold(Math.max(Math.floor(threshold / 10), 1))
}, [autoWithdrawThreshold])
const isClient = useIsClient()
return (
<>
<Checkbox
disabled={isClient && !isConfigured(wallet)}
label='enabled'
id='enabled'
name='enabled'
/>
<div className='my-4 border border-3 rounded'>
<div className='p-3'>
<h3 className='text-center text-muted'>desired balance</h3>

View File

@ -1,4 +1,4 @@
import { string, ValidationError, number, object, array, addMethod, boolean, date } from 'yup'
import { string, ValidationError, number, object, array, boolean, date } from './yup'
import {
BOOST_MIN, MAX_POLL_CHOICE_LENGTH, MAX_TITLE_LENGTH, MAX_POLL_NUM_CHOICES,
MIN_POLL_NUM_CHOICES, MAX_FORWARDS, BOOST_MULT, MAX_TERRITORY_DESC_LENGTH, POST_TYPES,
@ -6,19 +6,16 @@ import {
} from './constants'
import { SUPPORTED_CURRENCIES } from './currency'
import { NOSTR_MAX_RELAY_NUM, NOSTR_PUBKEY_BECH32, NOSTR_PUBKEY_HEX } from './nostr'
import { msatsToSats, numWithUnits, abbrNum, ensureB64, B64_URL_REGEX, HEX_REGEX } from './format'
import { msatsToSats, numWithUnits, abbrNum } from './format'
import * as usersFragments from '@/fragments/users'
import * as subsFragments from '@/fragments/subs'
import { isInvoicableMacaroon, isInvoiceMacaroon } from './macaroon'
import { TOR_REGEXP, parseNwcUrl } from './url'
import { datePivot } from './time'
import { decodeRune } from '@/lib/cln'
import bip39Words from './bip39-words'
const { SUB } = subsFragments
const { NAME_QUERY } = usersFragments
export async function ssValidate (schema, data, args) {
export async function validateSchema (schema, data, args) {
try {
if (typeof schema === 'function') {
return await schema(args).validate(data)
@ -33,159 +30,6 @@ export async function ssValidate (schema, data, args) {
}
}
export async function formikValidate (validate, data) {
const result = await validate(data)
if (Object.keys(result).length > 0) {
const [key, message] = Object.entries(result)[0]
throw new Error(`${key}: ${message}`)
}
return result
}
export async function walletValidate (walletDef, data) {
if (typeof walletDef.fieldValidation === 'function') {
return await formikValidate(walletDef.fieldValidation, data)
} else {
return await ssValidate(walletDef.fieldValidation, data)
}
}
addMethod(string, 'or', function (schemas, msg) {
return this.test({
name: 'or',
message: msg,
test: value => {
if (Array.isArray(schemas) && schemas.length > 1) {
const resee = schemas.map(schema => schema.isValidSync(value))
return resee.some(res => res)
} else {
throw new TypeError('Schemas is not correct array schema')
}
},
exclusive: false
})
})
addMethod(string, 'url', function (schemas, msg = 'invalid url') {
return this.test({
name: 'url',
message: msg,
test: value => {
try {
// eslint-disable-next-line no-new
new URL(value)
return true
} catch (e) {
try {
// eslint-disable-next-line no-new
new URL(`http://${value}`)
return true
} catch (e) {
return false
}
}
},
exclusive: false
})
})
addMethod(string, 'ws', function (schemas, msg = 'invalid websocket') {
return this.test({
name: 'ws',
message: msg,
test: value => {
if (typeof value === 'undefined') return true
try {
const url = new URL(value)
return url.protocol === 'ws:' || url.protocol === 'wss:'
} catch (e) {
return false
}
},
exclusive: false
})
})
addMethod(string, 'socket', function (schemas, msg = 'invalid socket') {
return this.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
})
})
addMethod(string, 'https', function () {
return this.test({
name: 'https',
message: 'https required',
test: (url) => {
try {
return new URL(url).protocol === 'https:'
} catch {
return false
}
}
})
})
addMethod(string, 'wss', function (msg) {
return this.test({
name: 'wss',
message: msg || 'wss required',
test: (url) => {
try {
return new URL(url).protocol === 'wss:'
} catch {
return false
}
}
})
})
addMethod(string, 'hex', function (msg) {
return this.test({
name: 'hex',
message: msg || 'invalid hex encoding',
test: (value) => !value || HEX_REGEX.test(value)
})
})
addMethod(string, 'nwcUrl', function () {
return this.test({
test: async (nwcUrl, context) => {
if (!nwcUrl) return true
// run validation in sequence to control order of errors
// inspired by https://github.com/jquense/yup/issues/851#issuecomment-1049705180
try {
await string().matches(/^nostr\+?walletconnect:\/\//, { message: 'must start with nostr+walletconnect://' }).validate(nwcUrl)
let relayUrl, walletPubkey, secret
try {
({ relayUrl, walletPubkey, secret } = parseNwcUrl(nwcUrl))
} catch {
// invalid URL error. handle as if pubkey validation failed to not confuse user.
throw new Error('pubkey must be 64 hex chars')
}
await string().required('pubkey required').trim().matches(NOSTR_PUBKEY_HEX, 'pubkey must be 64 hex chars').validate(walletPubkey)
await string().required('relay url required').trim().wss('relay must use wss://').validate(relayUrl)
await string().required('secret required').trim().matches(/^[0-9a-fA-F]{64}$/, 'secret must be 64 hex chars').validate(secret)
} catch (err) {
return context.createError({ message: err.message })
}
return true
}
})
})
const titleValidator = string().required('required').trim().max(
MAX_TITLE_LENGTH,
({ max, value }) => `-${Math.abs(max - value.length)} characters remaining`
@ -203,32 +47,12 @@ const nameValidator = string()
const intValidator = number().typeError('must be a number').integer('must be whole')
const floatValidator = number().typeError('must be a number')
const lightningAddressValidator = process.env.NODE_ENV === 'development'
export const lightningAddressValidator = process.env.NODE_ENV === 'development'
? string().or(
[string().matches(/^[\w_]+@localhost:\d+$/), string().matches(/^[\w_]+@app:\d+$/), string().email()],
'address is no good')
: string().email('address is no good')
const hexOrBase64Validator = string().test({
name: 'hex-or-base64',
message: 'invalid encoding',
test: (val) => {
if (typeof val === 'undefined') return true
try {
ensureB64(val)
return true
} catch {
return false
}
}
}).transform(val => {
try {
return ensureB64(val)
} catch {
return val
}
})
async function usernameExists (name, { client, models }) {
if (!client && !models) {
throw new Error('cannot check for user')
@ -363,56 +187,59 @@ export function advSchema (args) {
})
}
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'),
autoWithdrawMaxFeeTotal: intValidator.required('required').min(0, 'must be at least 0').max(1_000, 'must not exceed 1000')
}
export const lnAddrAutowithdrawSchema = object({
address: lightningAddressValidator.required('required').test({
name: 'address',
test: addr => !addr.endsWith('@stacker.news'),
message: 'automated withdrawals must be external'
}),
...autowithdrawSchemaMembers
export const autowithdrawSchemaMembers = object({
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))}`).transform(Number),
autoWithdrawMaxFeePercent: floatValidator.required('required').min(0, 'must be at least 0').max(50, 'must not exceed 50').transform(Number),
autoWithdrawMaxFeeTotal: intValidator.required('required').min(0, 'must be at least 0').max(1_000, 'must not exceed 1000').transform(Number)
})
export const LNDAutowithdrawSchema = 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
})
export const lnAddrSchema = ({ payerData, min, max, commentAllowed } = {}) =>
object({
addr: lightningAddressValidator.required('required'),
amount: (() => {
const schema = intValidator.required('required').positive('must be positive').min(
min || 1, `must be at least ${min || 1}`)
return max ? schema.max(max, `must be at most ${max}`) : schema
})(),
maxFee: intValidator.required('required').min(0, 'must be at least 0'),
comment: commentAllowed
? string().max(commentAllowed, `must be less than ${commentAllowed}`)
: string()
}).concat(object().shape(Object.keys(payerData || {}).reduce((accum, key) => {
const entry = payerData[key]
if (key === 'email') {
accum[key] = string().email()
} else if (key === 'identifier') {
accum[key] = boolean()
} else {
accum[key] = string()
}
if (entry?.mandatory) {
accum[key] = accum[key].required()
}
return accum
}, {})))
export const CLNAutowithdrawSchema = object({
socket: string().socket().required('required'),
rune: string().matches(B64_URL_REGEX, { message: 'invalid rune' }).required('required')
.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=invoice' })
}
if (decoded.restrictions.length !== 1 || decoded.restrictions[0].alternatives.length !== 1) {
return context.createError({ message: 'rune must be restricted to method=invoice only' })
}
if (decoded.restrictions[0].alternatives[0] !== 'method=invoice') {
return context.createError({ message: 'rune must be restricted to method=invoice only' })
}
return true
}
export const phoenixdSchema = object().shape({
url: string().url().required('required').trim(),
primaryPassword: string().length(64).hex()
.when(['secondaryPassword'], ([secondary], schema) => {
if (!secondary) return schema.required('required if secondary password not set')
return schema.test({
test: primary => secondary !== primary,
message: 'primary password cannot be the same as secondary password'
})
}),
secondaryPassword: string().length(64).hex()
.when(['primaryPassword'], ([primary], schema) => {
if (!primary) return schema.required('required if primary password not set')
return schema.test({
test: secondary => primary !== secondary,
message: 'secondary password cannot be the same as primary password'
})
}),
cert: hexOrBase64Validator,
...autowithdrawSchemaMembers
})
}, ['primaryPassword', 'secondaryPassword'])
export function bountySchema (args) {
return object({
@ -663,146 +490,6 @@ export const withdrawlSchema = object({
maxFee: intValidator.required('required').min(0, 'must be at least 0')
})
export const lnAddrSchema = ({ payerData, min, max, commentAllowed } = {}) =>
object({
addr: lightningAddressValidator.required('required'),
amount: (() => {
const schema = intValidator.required('required').positive('must be positive').min(
min || 1, `must be at least ${min || 1}`)
return max ? schema.max(max, `must be at most ${max}`) : schema
})(),
maxFee: intValidator.required('required').min(0, 'must be at least 0'),
comment: commentAllowed
? string().max(commentAllowed, `must be less than ${commentAllowed}`)
: string()
}).concat(object().shape(Object.keys(payerData || {}).reduce((accum, key) => {
const entry = payerData[key]
if (key === 'email') {
accum[key] = string().email()
} else if (key === 'identifier') {
accum[key] = boolean()
} else {
accum[key] = string()
}
if (entry?.mandatory) {
accum[key] = accum[key].required()
}
return accum
}, {})))
export const lnbitsSchema = object().shape({
url: process.env.NODE_ENV === 'development'
? string()
.or([string().matches(/^(http:\/\/)?localhost:\d+$/), string().url()], 'invalid url')
.required('required').trim()
: string().url().required('required').trim()
.test(async (url, context) => {
if (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
}),
adminKey: string().length(32).hex()
.when(['invoiceKey'], ([invoiceKey], schema) => {
if (!invoiceKey) return schema.required('required if invoice key not set')
return schema.test({
test: adminKey => adminKey !== invoiceKey,
message: 'admin key cannot be the same as invoice key'
})
}),
invoiceKey: string().length(32).hex()
.when(['adminKey'], ([adminKey], schema) => {
if (!adminKey) return schema.required('required if admin key not set')
return schema.test({
test: invoiceKey => adminKey !== invoiceKey,
message: 'invoice key cannot be the same as admin key'
})
}),
...autowithdrawSchemaMembers
// need to set order to avoid cyclic dependencies in Yup schema
// see https://github.com/jquense/yup/issues/176#issuecomment-367352042
}, ['adminKey', 'invoiceKey'])
export const nwcSchema = object().shape({
nwcUrl: string().nwcUrl().when(['nwcUrlRecv'], ([nwcUrlRecv], schema) => {
if (!nwcUrlRecv) return schema.required('required if connection for receiving not set')
return schema.test({
test: nwcUrl => nwcUrl !== nwcUrlRecv,
message: 'connection for sending cannot be the same as for receiving'
})
}),
nwcUrlRecv: string().nwcUrl().when(['nwcUrl'], ([nwcUrl], schema) => {
if (!nwcUrl) return schema.required('required if connection for sending not set')
return schema.test({
test: nwcUrlRecv => nwcUrlRecv !== nwcUrl,
message: 'connection for receiving cannot be the same as for sending'
})
}),
...autowithdrawSchemaMembers
}, ['nwcUrl', 'nwcUrlRecv'])
export const blinkSchema = object({
apiKey: string()
.required('required')
.matches(/^blink_[A-Za-z0-9]+$/, { message: 'must match pattern blink_A-Za-z0-9' }),
currency: string()
.transform(value => value ? value.toUpperCase() : 'BTC')
.oneOf(['USD', 'BTC'], 'must be BTC or USD')
})
export const lncSchema = object({
pairingPhrase: string()
.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
})
.required('required')
})
export const phoenixdSchema = object().shape({
url: string().url().required('required').trim(),
primaryPassword: string().length(64).hex()
.when(['secondaryPassword'], ([secondary], schema) => {
if (!secondary) return schema.required('required if secondary password not set')
return schema.test({
test: primary => secondary !== primary,
message: 'primary password cannot be the same as secondary password'
})
}),
secondaryPassword: string().length(64).hex()
.when(['primaryPassword'], ([primary], schema) => {
if (!primary) return schema.required('required if primary password not set')
return schema.test({
test: secondary => primary !== secondary,
message: 'secondary password cannot be the same as primary password'
})
}),
...autowithdrawSchemaMembers
}, ['primaryPassword', 'secondaryPassword'])
export const bioSchema = object({
text: string().required('required').trim()
})

167
lib/yup.js Normal file
View File

@ -0,0 +1,167 @@
import { addMethod, string, mixed } from 'yup'
import { parseNwcUrl } from './url'
import { NOSTR_PUBKEY_HEX } from './nostr'
import { ensureB64, HEX_REGEX } from './format'
function orFunc (schemas, msg) {
return this.test({
name: 'or',
message: msg,
test: value => {
if (Array.isArray(schemas) && schemas.length > 1) {
const resee = schemas.map(schema => schema.isValidSync(value))
return resee.some(res => res)
} else {
throw new TypeError('Schemas is not correct array schema')
}
},
exclusive: false
})
}
addMethod(mixed, 'or', orFunc)
addMethod(string, 'or', orFunc)
addMethod(string, 'hexOrBase64', function (schemas, msg = 'invalid hex or base64 encoding') {
return this.test({
name: 'hex-or-base64',
message: 'invalid encoding',
test: (val) => {
if (typeof val === 'undefined') return true
try {
ensureB64(val)
return true
} catch {
return false
}
}
}).transform(val => {
try {
return ensureB64(val)
} catch {
return val
}
})
})
addMethod(string, 'url', function (schemas, msg = 'invalid url') {
return this.test({
name: 'url',
message: msg,
test: value => {
try {
// eslint-disable-next-line no-new
new URL(value)
return true
} catch (e) {
try {
// eslint-disable-next-line no-new
new URL(`http://${value}`)
return true
} catch (e) {
return false
}
}
},
exclusive: false
})
})
addMethod(string, 'ws', function (schemas, msg = 'invalid websocket') {
return this.test({
name: 'ws',
message: msg,
test: value => {
if (typeof value === 'undefined') return true
try {
const url = new URL(value)
return url.protocol === 'ws:' || url.protocol === 'wss:'
} catch (e) {
return false
}
},
exclusive: false
})
})
addMethod(string, 'socket', function (schemas, msg = 'invalid socket') {
return this.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
})
})
addMethod(string, 'https', function () {
return this.test({
name: 'https',
message: 'https required',
test: (url) => {
try {
return new URL(url).protocol === 'https:'
} catch {
return false
}
}
})
})
addMethod(string, 'wss', function (msg) {
return this.test({
name: 'wss',
message: msg || 'wss required',
test: (url) => {
try {
return new URL(url).protocol === 'wss:'
} catch {
return false
}
}
})
})
addMethod(string, 'hex', function (msg) {
return this.test({
name: 'hex',
message: msg || 'invalid hex encoding',
test: (value) => !value || HEX_REGEX.test(value)
})
})
addMethod(string, 'nwcUrl', function () {
return this.test({
test: async (nwcUrl, context) => {
if (!nwcUrl) return true
// run validation in sequence to control order of errors
// inspired by https://github.com/jquense/yup/issues/851#issuecomment-1049705180
try {
await string().matches(/^nostr\+?walletconnect:\/\//, { message: 'must start with nostr+walletconnect://' }).validate(nwcUrl)
let relayUrl, walletPubkey, secret
try {
({ relayUrl, walletPubkey, secret } = parseNwcUrl(nwcUrl))
} catch {
// invalid URL error. handle as if pubkey validation failed to not confuse user.
throw new Error('pubkey must be 64 hex chars')
}
await string().required('pubkey required').trim().matches(NOSTR_PUBKEY_HEX, 'pubkey must be 64 hex chars').validate(walletPubkey)
await string().required('relay url required').trim().wss('relay must use wss://').validate(relayUrl)
await string().required('secret required').trim().matches(/^[0-9a-fA-F]{64}$/, 'secret must be 64 hex chars').validate(secret)
} catch (err) {
return context.createError({ message: err.message })
}
return true
}
})
})
export * from 'yup'

View File

@ -7,7 +7,7 @@ import { schnorr } from '@noble/curves/secp256k1'
import { createHash } from 'crypto'
import { datePivot } from '@/lib/time'
import { BALANCE_LIMIT_MSATS, INV_PENDING_LIMIT, LNURLP_COMMENT_MAX_LENGTH, USER_IDS_BALANCE_NO_LIMIT } from '@/lib/constants'
import { ssValidate, lud18PayerDataSchema } from '@/lib/validate'
import { validateSchema, lud18PayerDataSchema } from '@/lib/validate'
import assertGofacYourself from '@/api/resolvers/ofac'
export default async ({ query: { username, amount, nostr, comment, payerdata: payerData }, headers }, res) => {
@ -59,7 +59,7 @@ export default async ({ query: { username, amount, nostr, comment, payerdata: pa
}
try {
await ssValidate(lud18PayerDataSchema, parsedPayerData)
await validateSchema(lud18PayerDataSchema, parsedPayerData)
} catch (err) {
console.error('error validating payer data', err)
return res.status(400).json({ status: 'ERROR', reason: err.toString() })

View File

@ -9,12 +9,15 @@ import { useWallet } from '@/wallets/index'
import Info from '@/components/info'
import Text from '@/components/text'
import { autowithdrawInitial, AutowithdrawSettings } from '@/components/autowithdraw-shared'
import { canSend, isConfigured } from '@/wallets/common'
import { canReceive, canSend, isConfigured } from '@/wallets/common'
import { SSR } from '@/lib/constants'
import WalletButtonBar from '@/components/wallet-buttonbar'
import { useWalletConfigurator } from '@/wallets/config'
import { useMemo } from 'react'
import { useCallback, useMemo } from 'react'
import { useMe } from '@/components/me'
import validateWallet from '@/wallets/validate'
import { ValidationError } from 'yup'
import { useFormikContext } from 'formik'
export const getServerSideProps = getGetServerSideProps({ authRequired: true })
@ -47,10 +50,19 @@ export default function WalletSettings () {
}
}, [wallet, me])
// check if wallet uses the form-level validation built into Formik or a Yup schema
const validateProps = typeof wallet?.fieldValidation === 'function'
? { validate: wallet?.fieldValidation }
: { schema: wallet?.fieldValidation }
const validate = useCallback(async (data) => {
try {
await validateWallet(wallet.def, data, { abortEarly: false, topLevel: false })
} catch (error) {
if (error instanceof ValidationError) {
return error.inner.reduce((acc, error) => {
acc[error.path] = error.message
return acc
}, {})
}
throw error
}
}, [wallet.def])
return (
<CenterLayout>
@ -60,7 +72,7 @@ export default function WalletSettings () {
<Form
initial={initial}
enableReinitialize
{...validateProps}
validate={validate}
onSubmit={async ({ amount, ...values }) => {
try {
const newConfig = !isConfigured(wallet)
@ -81,18 +93,15 @@ export default function WalletSettings () {
}}
>
{wallet && <WalletFields wallet={wallet} />}
{wallet?.def.clientOnly
? (
<CheckboxGroup name='enabled'>
<Checkbox
disabled={!isConfigured(wallet)}
label='enabled'
name='enabled'
groupClassName='mb-0'
/>
</CheckboxGroup>
)
: <AutowithdrawSettings wallet={wallet} />}
<CheckboxGroup name='enabled'>
<Checkbox
disabled={!isConfigured(wallet)}
label='enabled'
name='enabled'
groupClassName='mb-0'
/>
</CheckboxGroup>
<ReceiveSettings walletDef={wallet.def} />
<WalletButtonBar
wallet={wallet} onDelete={async () => {
try {
@ -114,9 +123,14 @@ export default function WalletSettings () {
)
}
function ReceiveSettings ({ walletDef }) {
const { values } = useFormikContext()
return canReceive({ def: walletDef, config: values }) && <AutowithdrawSettings />
}
function WalletFields ({ wallet }) {
return wallet.def.fields
.map(({ name, label = '', type, help, optional, editable, clientOnly, serverOnly, ...props }, i) => {
.map(({ name, label = '', type, help, optional, editable, requiredWithout, validate, clientOnly, serverOnly, ...props }, i) => {
const rawProps = {
...props,
name,

View File

@ -11,7 +11,7 @@ import { useToast } from '@/components/toast'
export const getServerSideProps = getGetServerSideProps({ authRequired: true })
export default function Wallet ({ ssrData }) {
const { wallets, setPriorities, reloadLocalWallets } = useWallets()
const { wallets, setPriorities } = useWallets()
const toast = useToast()
const isClient = useIsClient()
const [sourceIndex, setSourceIndex] = useState(null)
@ -28,8 +28,7 @@ export default function Wallet ({ ssrData }) {
.map((w, i) => ({ wallet: w, priority: i }))
await setPriorities(priorities)
reloadLocalWallets()
}, [setPriorities, reloadLocalWallets, wallets])
}, [setPriorities, wallets])
const onDragStart = useCallback((i) => (e) => {
// e.dataTransfer.dropEffect = 'move'

View File

@ -1,4 +1,5 @@
import { blinkSchema } from '@/lib/validate'
import { string } from '@/lib/yup'
export const galoyBlinkUrl = 'https://api.blink.sv/graphql'
export const galoyBlinkDashboardUrl = 'https://dashboard.blink.sv/'
@ -7,7 +8,6 @@ export const name = 'blink'
export const walletType = 'BLINK'
export const walletField = 'walletBlink'
export const fieldValidation = blinkSchema
export const clientOnly = true
export const fields = [
{
@ -15,7 +15,10 @@ export const fields = [
label: 'api key',
type: 'password',
help: `you can get an API key from [Blink Dashboard](${galoyBlinkDashboardUrl})`,
placeholder: 'blink_...'
placeholder: 'blink_...',
clientOnly: true,
validate: string()
.matches(/^blink_[A-Za-z0-9]+$/, { message: 'must match pattern blink_A-Za-z0-9' })
},
{
name: 'currency',
@ -25,7 +28,11 @@ export const fields = [
placeholder: 'BTC',
optional: true,
clear: true,
autoComplete: 'off'
autoComplete: 'off',
clientOnly: true,
validate: string()
.transform(value => value ? value.toUpperCase() : 'BTC')
.oneOf(['USD', 'BTC'], 'must be BTC or USD')
}
]

View File

@ -1,4 +1,7 @@
import { decodeRune } from '@/lib/cln'
import { B64_URL_REGEX } from '@/lib/format'
import { CLNAutowithdrawSchema } from '@/lib/validate'
import { string } from '@/lib/yup'
export const name = 'cln'
export const walletType = 'CLN'
@ -13,7 +16,8 @@ export const fields = [
placeholder: '55.5.555.55:3010',
hint: 'tor or clearnet',
clear: true,
serverOnly: true
serverOnly: true,
validate: string().socket()
},
{
name: 'rune',
@ -25,7 +29,25 @@ export const fields = [
placeholder: 'S34KtUW-6gqS_hD_9cc_PNhfF-NinZyBOCgr1aIrark9NCZtZXRob2Q9aW52b2ljZQ==',
hint: 'must be restricted to method=invoice',
clear: true,
serverOnly: true
serverOnly: true,
validate: 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=invoice' })
}
if (decoded.restrictions.length !== 1 || decoded.restrictions[0].alternatives.length !== 1) {
return context.createError({ message: 'rune must be restricted to method=invoice only' })
}
if (decoded.restrictions[0].alternatives[0] !== 'method=invoice') {
return context.createError({ message: 'rune must be restricted to method=invoice only' })
}
return true
}
})
},
{
name: 'cert',
@ -35,7 +57,8 @@ export const fields = [
optional: 'optional if from [CA](https://en.wikipedia.org/wiki/Certificate_authority) (e.g. voltage)',
hint: 'hex or base64 encoded',
clear: true,
serverOnly: true
serverOnly: true,
validate: string().hexOrBase64()
}
]

View File

@ -62,7 +62,8 @@ export function isClientField (f) {
function checkFields ({ fields, config }) {
// a wallet is configured if all of its required fields are set
let val = fields.every(f => {
return f.optional ? true : !!config?.[f.name]
if (f.optional && !f.requiredWithout) return true
return !!config?.[f.name]
})
// however, a wallet is not configured if all fields are optional and none are set
@ -93,5 +94,5 @@ export function canSend ({ def, config }) {
}
export function canReceive ({ def, config }) {
return !def.clientOnly && isReceiveConfigured({ def, config })
return def.fields.some(f => f.serverOnly) && isReceiveConfigured({ def, config })
}

View File

@ -5,9 +5,9 @@ import { canReceive, canSend, getStorageKey } from './common'
import { useMutation } from '@apollo/client'
import { generateMutation } from './graphql'
import { REMOVE_WALLET } from '@/fragments/wallet'
import { walletValidate } from '@/lib/validate'
import { useWalletLogger } from '@/components/wallet-logger'
import { useWallets } from '.'
import validateWallet from './validate'
export function useWalletConfigurator (wallet) {
const { me } = useMe()
@ -20,11 +20,14 @@ export function useWalletConfigurator (wallet) {
const _saveToServer = useCallback(async (serverConfig, clientConfig, validateLightning) => {
const { serverWithShared, settings, clientOnly } = siftConfig(wallet.def.fields, { ...serverConfig, ...clientConfig })
const vaultEntries = []
if (clientOnly) {
if (clientOnly && isActive) {
for (const [key, value] of Object.entries(clientOnly)) {
vaultEntries.push({ key, value: encrypt(value) })
if (value) {
vaultEntries.push({ key, value: encrypt(value) })
}
}
}
await upsertWallet({ variables: { ...serverWithShared, settings, validateLightning, vaultEntries } })
}, [encrypt, isActive, wallet.def.fields])
@ -40,7 +43,7 @@ export function useWalletConfigurator (wallet) {
let serverConfig = serverWithShared
if (canSend({ def: wallet.def, config: clientConfig })) {
let transformedConfig = await walletValidate(wallet.def, clientWithShared)
let transformedConfig = await validateWallet(wallet.def, clientWithShared)
if (transformedConfig) {
clientConfig = Object.assign(clientConfig, transformedConfig)
}
@ -51,7 +54,7 @@ export function useWalletConfigurator (wallet) {
}
}
} else if (canReceive({ def: wallet.def, config: serverConfig })) {
const transformedConfig = await walletValidate(wallet.def, serverConfig)
const transformedConfig = await validateWallet(wallet.def, serverConfig)
if (transformedConfig) {
serverConfig = Object.assign(serverConfig, transformedConfig)
}
@ -62,6 +65,15 @@ export function useWalletConfigurator (wallet) {
return { clientConfig, serverConfig }
}, [wallet])
const _detachFromServer = useCallback(async () => {
await removeWallet({ variables: { id: wallet.config.id } })
}, [wallet.config?.id])
const _detachFromLocal = useCallback(async () => {
window.localStorage.removeItem(getStorageKey(wallet.def.name, me?.id))
reloadLocalWallets()
}, [me?.id, wallet.def.name, reloadLocalWallets])
const save = useCallback(async (newConfig, validateLightning = true) => {
const { clientConfig, serverConfig } = await _validate(newConfig, validateLightning)
@ -71,20 +83,18 @@ export function useWalletConfigurator (wallet) {
} else {
if (canSend({ def: wallet.def, config: clientConfig })) {
await _saveToLocal(clientConfig)
} else {
// if it previously had a client config, remove it
await _detachFromLocal()
}
if (canReceive({ def: wallet.def, config: serverConfig })) {
await _saveToServer(serverConfig, clientConfig, validateLightning)
} else {
// if it previously had a server config, remove it
await _detachFromServer()
}
}
}, [isActive, _saveToServer, _saveToLocal, _validate])
const _detachFromServer = useCallback(async () => {
await removeWallet({ variables: { id: wallet.config.id } })
}, [wallet.config?.id])
const _detachFromLocal = useCallback(async () => {
window.localStorage.removeItem(getStorageKey(wallet.def.name, me?.id))
}, [me?.id, wallet.def.name])
}, [isActive, _saveToServer, _saveToLocal, _validate, _detachFromLocal, _detachFromServer])
const detach = useCallback(async () => {
if (isActive) {

View File

@ -18,7 +18,7 @@ function useLocalWallets () {
const [wallets, setWallets] = useState([])
const loadWallets = useCallback(() => {
// form wallets into a list of { config, def }
// form wallets from local storage into a list of { config, def }
const wallets = walletDefs.map(w => {
try {
const storageKey = getStorageKey(w.name, me?.id)
@ -66,15 +66,28 @@ export function WalletsProvider ({ children }) {
}
// the specific wallet config on the server is stored in wallet.wallet
// on the client, it's stored in unnested
// on the client, it's stored unnested
return { config: { ...config, ...w.wallet }, def }
}) ?? []
// merge wallets on name
// merge wallets on name like: { ...unconfigured, ...localConfig, ...serverConfig }
const merged = {}
for (const wallet of [...walletDefsOnly, ...localWallets, ...wallets]) {
merged[wallet.def.name] = { ...merged[wallet.def.name], ...wallet }
merged[wallet.def.name] = {
def: wallet.def,
config: {
...merged[wallet.def.name]?.config,
...Object.fromEntries(
Object.entries(wallet.config ?? {}).map(([key, value]) => [
key,
value ?? merged[wallet.def.name]?.config?.[key]
])
)
}
}
}
// sort by priority, then add status field
return Object.values(merged)
.sort(walletPrioritySort)
.map(w => ({ ...w, status: w.config?.enabled ? Status.Enabled : Status.Disabled }))
@ -87,6 +100,7 @@ export function WalletsProvider ({ children }) {
}
if (wallet.config?.id) {
// set priority on server if it has an id
await setWalletPriority({ variables: { id: wallet.config.id, priority } })
} else {
const storageKey = getStorageKey(wallet.def.name, me?.id)
@ -95,9 +109,14 @@ export function WalletsProvider ({ children }) {
window.localStorage.setItem(storageKey, JSON.stringify(newConfig))
}
}
}, [setWalletPriority, me?.id])
// reload local wallets if any priorities were set
if (priorities.length > 0) {
reloadLocalWallets()
}
}, [setWalletPriority, me?.id, reloadLocalWallets])
// provides priority sorted wallets to children
// provides priority sorted wallets to children, a function to reload local wallets,
// and a function to set priorities
return (
<WalletsContext.Provider value={{ wallets, reloadLocalWallets, setPriorities }}>
{children}
@ -117,6 +136,7 @@ export function useWallet (name) {
return wallets.find(w => w.def.name === name)
}
// return the first enabled wallet that is available and can send
return wallets
.filter(w => !w.def.isAvailable || w.def.isAvailable())
.filter(w => w.config?.enabled && canSend(w))[0]

View File

@ -1,10 +1,9 @@
import { lnAddrAutowithdrawSchema } from '@/lib/validate'
import { lightningAddressValidator } from '@/lib/validate'
export const name = 'lightning-address'
export const shortName = 'lnAddr'
export const walletType = 'LIGHTNING_ADDRESS'
export const walletField = 'walletLightningAddress'
export const fieldValidation = lnAddrAutowithdrawSchema
export const fields = [
{
@ -12,7 +11,12 @@ export const fields = [
label: 'lightning address',
type: 'text',
autoComplete: 'off',
serverOnly: true
serverOnly: true,
validate: lightningAddressValidator.test({
name: 'address',
test: addr => !addr.toLowerCase().endsWith('@stacker.news'),
message: 'automated withdrawals must be external'
})
}
]

View File

@ -1,15 +1,37 @@
import { lnbitsSchema } from '@/lib/validate'
import { TOR_REGEXP } from '@/lib/url'
import { string } from '@/lib/yup'
export const name = 'lnbits'
export const walletType = 'LNBITS'
export const walletField = 'walletLNbits'
export const fieldValidation = lnbitsSchema
export const fields = [
{
name: 'url',
label: 'lnbits url',
type: 'text'
type: 'text',
required: true,
validate: 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 (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
})
},
{
name: 'invoiceKey',
@ -17,7 +39,8 @@ export const fields = [
type: 'password',
optional: 'for receiving',
serverOnly: true,
editable: false
requiredWithout: 'adminKey',
validate: string().hex().length(32)
},
{
name: 'adminKey',
@ -25,7 +48,8 @@ export const fields = [
type: 'password',
optional: 'for sending',
clientOnly: true,
editable: false
requiredWithout: 'invoiceKey',
validate: string().hex().length(32)
}
]

View File

@ -1,10 +1,10 @@
import { lncSchema } from '@/lib/validate'
import bip39Words from '@/lib/bip39-words'
import { string } from '@/lib/yup'
export const name = 'lnc'
export const walletType = 'LNC'
export const walletField = 'walletLNC'
export const clientOnly = true
export const fieldValidation = lncSchema
export const fields = [
{
@ -13,7 +13,25 @@ export const fields = [
type: 'password',
help: 'We only need permissions for the uri `/lnrpc.Lightning/SendPaymentSync`\n\nCreate a budgeted account with narrow permissions:\n\n```$ litcli accounts create --balance <budget>```\n\n```$ litcli sessions add --type custom --label <your label> --account_id <account_id> --uri /lnrpc.Lightning/SendPaymentSync```\n\nGrab the `pairing_secret_mnemonic` from the output and paste it here.',
editable: false,
clientOnly: true
clientOnly: true,
validate: string()
.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
})
},
{
name: 'localKey',

View File

@ -1,9 +1,9 @@
import { LNDAutowithdrawSchema } from '@/lib/validate'
import { isInvoicableMacaroon, isInvoiceMacaroon } from '@/lib/macaroon'
import { string } from '@/lib/yup'
export const name = 'lnd'
export const walletType = 'LND'
export const walletField = 'walletLND'
export const fieldValidation = LNDAutowithdrawSchema
export const fields = [
{
@ -13,7 +13,8 @@ export const fields = [
placeholder: '55.5.555.55:10001',
hint: 'tor or clearnet',
clear: true,
serverOnly: true
serverOnly: true,
validate: string().socket()
},
{
name: 'macaroon',
@ -26,7 +27,12 @@ export const fields = [
placeholder: 'AgEDbG5kAlgDChCn7YgfWX7uTkQQgXZ2uahNEgEwGhYKB2FkZHJlc3MSBHJlYWQSBXdyaXRlGhcKCGludm9pY2VzEgRyZWFkEgV3cml0ZRoPCgdvbmNoYWluEgRyZWFkAAAGIJkMBrrDV0npU90JV0TGNJPrqUD8m2QYoTDjolaL6eBs',
hint: 'hex or base64 encoded',
clear: true,
serverOnly: true
serverOnly: true,
validate: string().hexOrBase64().test({
name: 'macaroon',
test: v => isInvoiceMacaroon(v) || isInvoicableMacaroon(v),
message: 'not an invoice macaroon or an invoicable macaroon'
})
},
{
name: 'cert',
@ -36,7 +42,8 @@ export const fields = [
optional: 'optional if from [CA](https://en.wikipedia.org/wiki/Certificate_authority) (e.g. voltage)',
hint: 'hex or base64 encoded',
clear: true,
serverOnly: true
serverOnly: true,
validate: string().hexOrBase64()
}
]

View File

@ -1,12 +1,11 @@
import { Relay } from '@/lib/nostr'
import { parseNwcUrl } from '@/lib/url'
import { nwcSchema } from '@/lib/validate'
import { string } from '@/lib/yup'
import { finalizeEvent, nip04, verifyEvent } from 'nostr-tools'
export const name = 'nwc'
export const walletType = 'NWC'
export const walletField = 'walletNWC'
export const fieldValidation = nwcSchema
export const fields = [
{
@ -15,7 +14,8 @@ export const fields = [
type: 'password',
optional: 'for sending',
clientOnly: true,
editable: false
requiredWithout: 'nwcUrlRecv',
validate: string().nwcUrl()
},
{
name: 'nwcUrlRecv',
@ -23,7 +23,8 @@ export const fields = [
type: 'password',
optional: 'for receiving',
serverOnly: true,
editable: false
requiredWithout: 'nwcUrl',
validate: string().nwcUrl()
}
]

View File

@ -1,16 +1,16 @@
import { phoenixdSchema } from '@/lib/validate'
import { string } from '@/lib/yup'
export const name = 'phoenixd'
export const walletType = 'PHOENIXD'
export const walletField = 'walletPhoenixd'
export const fieldValidation = phoenixdSchema
// configure wallet fields
export const fields = [
{
name: 'url',
label: 'url',
type: 'text'
type: 'text',
validate: string().url().trim()
},
{
name: 'primaryPassword',
@ -19,7 +19,8 @@ export const fields = [
optional: 'for sending',
help: 'You can find the primary password as `http-password` in your phoenixd configuration file as mentioned [here](https://phoenix.acinq.co/server/api#security).',
clientOnly: true,
editable: false
requiredWithout: 'secondaryPassword',
validate: string().length(64).hex()
},
{
name: 'secondaryPassword',
@ -28,7 +29,8 @@ export const fields = [
optional: 'for receiving',
help: 'You can find the secondary password as `http-password-limited-access` in your phoenixd configuration file as mentioned [here](https://phoenix.acinq.co/server/api#security).',
serverOnly: true,
editable: false
requiredWithout: 'primaryPassword',
validate: string().length(64).hex()
}
]
@ -38,6 +40,3 @@ export const card = {
subtitle: 'use [phoenixd](https://phoenix.acinq.co/server) for payments',
badges: ['send & receive']
}
// phoenixd::TODO
// set validation schema

View File

@ -42,11 +42,12 @@ export async function createInvoice (userId, { msats, description, descriptionHa
for (const wallet of wallets) {
const w = walletDefs.find(w => w.walletType === wallet.def.walletType)
try {
const { walletType, walletField, createInvoice } = w
if (!canReceive({ def: w, config: wallet })) {
continue
}
const { walletType, walletField, createInvoice } = w
const walletFull = await models.wallet.findFirst({
where: {
userId,

91
wallets/validate.js Normal file
View File

@ -0,0 +1,91 @@
/*
we want to take all the validate members from the provided wallet
and compose into a single yup schema for formik validation ...
the validate member can be on of:
- a yup schema
- a function that throws on an invalid value
- a regular expression that must match
*/
import { autowithdrawSchemaMembers } from '@/lib/validate'
import * as Yup from '@/lib/yup'
import { canReceive } from './common'
export default async function validateWallet (walletDef, data, options = { abortEarly: true, topLevel: true }) {
let schema = composeWalletSchema(walletDef)
if (canReceive({ def: walletDef, config: data })) {
schema = schema.concat(autowithdrawSchemaMembers)
}
await schema.validate(data, options)
const casted = schema.cast(data, { assert: false })
if (options.topLevel && walletDef.validate) {
await walletDef.validate(casted)
}
return casted
}
function createFieldSchema (name, validate) {
if (!validate) {
throw new Error(`No validation provided for field ${name}`)
}
if (Yup.isSchema(validate)) {
// If validate is already a Yup schema, return it directly
return validate
} else if (typeof validate === 'function') {
// If validate is a function, create a custom Yup test
return Yup.mixed().test({
name,
test: (value, context) => {
try {
validate(value)
return true
} catch (error) {
return context.createError({ message: error.message })
}
}
})
} else if (validate instanceof RegExp) {
// If validate is a regular expression, use Yup.matches
return Yup.string().matches(validate, `${name} is invalid`)
} else {
throw new Error(`validate for ${name} must be a yup schema, function, or regular expression`)
}
}
function composeWalletSchema (walletDef) {
const { fields } = walletDef
const schemaShape = fields.reduce((acc, field) => {
const { name, validate, optional, requiredWithout } = field
acc[name] = createFieldSchema(name, validate)
if (!optional) {
acc[name] = acc[name].required('Required')
} else if (requiredWithout) {
acc[name] = acc[name].when([requiredWithout], ([pairSetting], schema) => {
if (!pairSetting) return schema.required(`required if ${requiredWithout} not set`)
return Yup.mixed().or([schema.test({
test: value => value !== pairSetting,
message: `${name} cannot be the same as ${requiredWithout}`
}), Yup.mixed().notRequired()])
})
}
return acc
}, {})
// we use Object.keys(schemaShape).reverse() to avoid cyclic dependencies in Yup schema
// see https://github.com/jquense/yup/issues/176#issuecomment-367352042
const composedSchema = Yup.object().shape(schemaShape, Object.keys(schemaShape).reverse()).concat(Yup.object({
enabled: Yup.boolean(),
priority: Yup.number().min(0, 'must be at least 0').max(100, 'must be at most 100')
}))
return composedSchema
}

View File

@ -1,22 +1,15 @@
export const name = 'webln'
export const walletType = 'WEBLN'
export const walletField = 'walletWebLN'
export const clientOnly = true
export const validate = ({ enabled }) => {
if (enabled && typeof window?.webln === 'undefined') {
throw new Error('no WebLN provider found')
}
}
export const fields = []
export const fieldValidation = ({ enabled }) => {
if (typeof window?.webln === 'undefined') {
// don't prevent disabling WebLN if no WebLN provider found
if (enabled) {
return {
enabled: 'no WebLN provider found'
}
}
}
return {}
}
export const card = {
title: 'WebLN',
subtitle: 'use a [WebLN provider](https://www.webln.guide/ressources/webln-providers) for payments',