Merge pull request #1507 from stackernews/wallet-fantasy-refactor
Fantasy wallet refactor
This commit is contained in:
commit
e375cc7c76
|
@ -19,6 +19,7 @@ import chainFee from './chainFee'
|
|||
import { GraphQLScalarType, Kind } from 'graphql'
|
||||
import { createIntScalar } from 'graphql-scalar'
|
||||
import paidAction from './paidAction'
|
||||
import vault from './vault'
|
||||
|
||||
const date = new GraphQLScalarType({
|
||||
name: 'Date',
|
||||
|
@ -55,4 +56,4 @@ const limit = createIntScalar({
|
|||
|
||||
export default [user, item, message, wallet, lnurl, notifications, invite, sub,
|
||||
upload, search, growth, rewards, referrals, price, admin, blockHeight, chainFee,
|
||||
{ JSONObject }, { Date: date }, { Limit: limit }, paidAction]
|
||||
{ JSONObject }, { Date: date }, { Limit: limit }, paidAction, vault]
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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 } })
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 })
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -0,0 +1,75 @@
|
|||
import { E_VAULT_KEY_EXISTS, GqlAuthenticationError, GqlInputError } from '@/lib/error'
|
||||
|
||||
export default {
|
||||
Query: {
|
||||
getVaultEntry: async (parent, { key }, { me, models }, info) => {
|
||||
if (!me) throw new GqlAuthenticationError()
|
||||
if (!key) throw new GqlInputError('must have key')
|
||||
|
||||
const k = await models.vault.findUnique({
|
||||
where: {
|
||||
key,
|
||||
userId: me.id
|
||||
}
|
||||
})
|
||||
return k
|
||||
},
|
||||
getVaultEntries: async (parent, { keysFilter }, { me, models }, info) => {
|
||||
if (!me) throw new GqlAuthenticationError()
|
||||
|
||||
const entries = await models.vaultEntry.findMany({
|
||||
where: {
|
||||
userId: me.id,
|
||||
key: keysFilter?.length
|
||||
? {
|
||||
in: keysFilter
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
})
|
||||
return entries
|
||||
}
|
||||
},
|
||||
Mutation: {
|
||||
// atomic vault migration
|
||||
updateVaultKey: async (parent, { entries, hash }, { me, models }) => {
|
||||
if (!me) throw new GqlAuthenticationError()
|
||||
if (!hash) throw new GqlInputError('hash required')
|
||||
const txs = []
|
||||
|
||||
const { vaultKeyHash: oldKeyHash } = await models.user.findUnique({ where: { id: me.id } })
|
||||
if (oldKeyHash) {
|
||||
if (oldKeyHash !== hash) {
|
||||
throw new GqlInputError('vault key already set', E_VAULT_KEY_EXISTS)
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
txs.push(models.user.update({
|
||||
where: { id: me.id },
|
||||
data: { vaultKeyHash: hash }
|
||||
}))
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
txs.push(models.vaultEntry.update({
|
||||
where: { userId_key: { userId: me.id, key: entry.key } },
|
||||
data: { value: entry.value, iv: entry.iv }
|
||||
}))
|
||||
}
|
||||
await models.$transaction(txs)
|
||||
return true
|
||||
},
|
||||
clearVault: async (parent, args, { me, models }) => {
|
||||
if (!me) throw new GqlAuthenticationError()
|
||||
const txs = []
|
||||
txs.push(models.user.update({
|
||||
where: { id: me.id },
|
||||
data: { vaultKeyHash: '' }
|
||||
}))
|
||||
txs.push(models.vaultEntry.deleteMany({ where: { userId: me.id } }))
|
||||
await models.$transaction(txs)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
|
@ -12,38 +12,62 @@ 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'
|
||||
import { bolt11Tags } from '@/lib/bolt11'
|
||||
import { finalizeHodlInvoice } from 'worker/wallet'
|
||||
import walletDefs from 'wallets/server'
|
||||
import { generateResolverName, generateTypeDefName } from '@/lib/wallet'
|
||||
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'
|
||||
import { canReceive } from '@/wallets/common'
|
||||
|
||||
function injectResolvers (resolvers) {
|
||||
console.group('injected GraphQL resolvers:')
|
||||
for (const w of walletDefs) {
|
||||
const resolverName = generateResolverName(w.walletField)
|
||||
for (const walletDef of walletDefs) {
|
||||
const resolverName = generateResolverName(walletDef.walletField)
|
||||
console.log(resolverName)
|
||||
resolvers.Mutation[resolverName] = async (parent, { settings, validateLightning, vaultEntries, ...data }, { me, models }) => {
|
||||
console.log('resolving', resolverName, { settings, validateLightning, vaultEntries, ...data })
|
||||
|
||||
resolvers.Mutation[resolverName] = async (parent, { settings, priorityOnly, ...data }, { me, models }) => {
|
||||
// allow transformation of the data on validation (this is optional ... won't do anything if not implemented)
|
||||
if (!priorityOnly) {
|
||||
const validData = await walletValidate(w, { ...data, ...settings })
|
||||
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] })
|
||||
}
|
||||
let existingVaultEntries
|
||||
if (typeof vaultEntries === 'undefined' && data.id) {
|
||||
// this mutation was sent from an unsynced client
|
||||
// to pass validation, we need to add the existing vault entries for validation
|
||||
// in case the client is removing the receiving config
|
||||
existingVaultEntries = await models.vaultEntry.findMany({
|
||||
where: {
|
||||
walletId: Number(data.id)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const validData = await validateWallet(walletDef,
|
||||
{ ...data, ...settings, vaultEntries: vaultEntries ?? existingVaultEntries },
|
||||
{ serverSide: true })
|
||||
if (validData) {
|
||||
data && Object.keys(validData).filter(key => key in data).forEach(key => { data[key] = validData[key] })
|
||||
settings && Object.keys(validData).filter(key => key in settings).forEach(key => { settings[key] = validData[key] })
|
||||
}
|
||||
|
||||
return await upsertWallet({
|
||||
wallet: { field: w.walletField, type: w.walletType },
|
||||
testCreateInvoice: (data) => w.testCreateInvoice(data, { me, models })
|
||||
}, { settings, data, priorityOnly }, { me, models })
|
||||
wallet: {
|
||||
field: walletDef.walletField,
|
||||
type: walletDef.walletType
|
||||
},
|
||||
testCreateInvoice:
|
||||
walletDef.testCreateInvoice && validateLightning && canReceive({ def: walletDef, config: data })
|
||||
? (data) => walletDef.testCreateInvoice(data, { me, models })
|
||||
: null
|
||||
}, {
|
||||
settings,
|
||||
data,
|
||||
vaultEntries
|
||||
}, { me, models })
|
||||
}
|
||||
}
|
||||
console.groupEnd()
|
||||
|
@ -142,6 +166,9 @@ const resolvers = {
|
|||
where: {
|
||||
userId: me.id,
|
||||
id: Number(id)
|
||||
},
|
||||
include: {
|
||||
vaultEntries: true
|
||||
}
|
||||
})
|
||||
},
|
||||
|
@ -154,6 +181,9 @@ const resolvers = {
|
|||
where: {
|
||||
userId: me.id,
|
||||
type
|
||||
},
|
||||
include: {
|
||||
vaultEntries: true
|
||||
}
|
||||
})
|
||||
return wallet
|
||||
|
@ -164,8 +194,14 @@ const resolvers = {
|
|||
}
|
||||
|
||||
return await models.wallet.findMany({
|
||||
include: {
|
||||
vaultEntries: true
|
||||
},
|
||||
where: {
|
||||
userId: me.id
|
||||
},
|
||||
orderBy: {
|
||||
priority: 'asc'
|
||||
}
|
||||
})
|
||||
},
|
||||
|
@ -418,7 +454,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 }
|
||||
|
@ -506,6 +542,15 @@ const resolvers = {
|
|||
}
|
||||
return { id }
|
||||
},
|
||||
setWalletPriority: async (parent, { id, priority }, { me, models }) => {
|
||||
if (!me) {
|
||||
throw new GqlAuthenticationError()
|
||||
}
|
||||
|
||||
await models.wallet.update({ where: { userId: me.id, id: Number(id) }, data: { priority } })
|
||||
|
||||
return true
|
||||
},
|
||||
removeWallet: async (parent, { id }, { me, models }) => {
|
||||
if (!me) {
|
||||
throw new GqlAuthenticationError()
|
||||
|
@ -627,13 +672,13 @@ export const addWalletLog = async ({ wallet, level, message }, { models }) => {
|
|||
}
|
||||
|
||||
async function upsertWallet (
|
||||
{ wallet, testCreateInvoice }, { settings, data, priorityOnly }, { me, models }) {
|
||||
{ wallet, testCreateInvoice }, { settings, data, vaultEntries }, { me, models }) {
|
||||
if (!me) {
|
||||
throw new GqlAuthenticationError()
|
||||
}
|
||||
assertApiKeyNotPermitted({ me })
|
||||
|
||||
if (testCreateInvoice && !priorityOnly) {
|
||||
if (testCreateInvoice) {
|
||||
try {
|
||||
await testCreateInvoice(data)
|
||||
} catch (err) {
|
||||
|
@ -646,65 +691,103 @@ async function upsertWallet (
|
|||
}
|
||||
}
|
||||
|
||||
const { id, ...walletData } = data
|
||||
const {
|
||||
autoWithdrawThreshold,
|
||||
autoWithdrawMaxFeePercent,
|
||||
autoWithdrawMaxFeeTotal,
|
||||
enabled,
|
||||
priority
|
||||
} = settings
|
||||
const { id, enabled, priority, ...walletData } = data
|
||||
|
||||
const txs = [
|
||||
models.user.update({
|
||||
where: { id: me.id },
|
||||
data: {
|
||||
autoWithdrawMaxFeePercent,
|
||||
autoWithdrawThreshold,
|
||||
autoWithdrawMaxFeeTotal
|
||||
}
|
||||
})
|
||||
]
|
||||
const txs = []
|
||||
|
||||
if (id) {
|
||||
const oldVaultEntries = await models.vaultEntry.findMany({ where: { userId: me.id, walletId: Number(id) } })
|
||||
|
||||
// createMany is the set difference of the new - old
|
||||
// deleteMany is the set difference of the old - new
|
||||
// updateMany is the intersection of the old and new
|
||||
const difference = (a = [], b = [], key = 'key') => a.filter(x => !b.find(y => y[key] === x[key]))
|
||||
const intersectionMerge = (a = [], b = [], key = 'key') => a.filter(x => b.find(y => y[key] === x[key]))
|
||||
.map(x => ({ [key]: x[key], ...b.find(y => y[key] === x[key]) }))
|
||||
|
||||
txs.push(
|
||||
models.wallet.update({
|
||||
where: { id: Number(id), userId: me.id },
|
||||
data: {
|
||||
enabled,
|
||||
priority,
|
||||
[wallet.field]: {
|
||||
update: {
|
||||
where: { walletId: Number(id) },
|
||||
data: walletData
|
||||
}
|
||||
}
|
||||
// client only wallets has no walletData
|
||||
...(Object.keys(walletData).length > 0
|
||||
? {
|
||||
[wallet.field]: {
|
||||
update: {
|
||||
where: { walletId: Number(id) },
|
||||
data: walletData
|
||||
}
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
...(vaultEntries
|
||||
? {
|
||||
vaultEntries: {
|
||||
deleteMany: difference(oldVaultEntries, vaultEntries, 'key').map(({ key }) => ({
|
||||
userId: me.id, key
|
||||
})),
|
||||
create: difference(vaultEntries, oldVaultEntries, 'key').map(({ key, iv, value }) => ({
|
||||
key, iv, value, userId: me.id
|
||||
})),
|
||||
update: intersectionMerge(oldVaultEntries, vaultEntries, 'key').map(({ key, iv, value }) => ({
|
||||
where: { userId_key: { userId: me.id, key } },
|
||||
data: { value, iv }
|
||||
}))
|
||||
}
|
||||
}
|
||||
: {})
|
||||
|
||||
},
|
||||
include: {
|
||||
vaultEntries: true
|
||||
}
|
||||
})
|
||||
)
|
||||
} else {
|
||||
txs.push(
|
||||
models.wallet.create({
|
||||
include: {
|
||||
vaultEntries: true
|
||||
},
|
||||
data: {
|
||||
enabled,
|
||||
priority,
|
||||
userId: me.id,
|
||||
type: wallet.type,
|
||||
[wallet.field]: {
|
||||
create: walletData
|
||||
}
|
||||
// client only wallets has no walletData
|
||||
...(Object.keys(walletData).length > 0 ? { [wallet.field]: { create: walletData } } : {}),
|
||||
...(vaultEntries
|
||||
? {
|
||||
vaultEntries: {
|
||||
createMany: {
|
||||
data: vaultEntries?.map(({ key, iv, value }) => ({ key, iv, value, userId: me.id }))
|
||||
}
|
||||
}
|
||||
}
|
||||
: {})
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (settings) {
|
||||
txs.push(
|
||||
models.user.update({
|
||||
where: { id: me.id },
|
||||
data: settings
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
txs.push(
|
||||
models.walletLog.createMany({
|
||||
data: {
|
||||
userId: me.id,
|
||||
wallet: wallet.type,
|
||||
level: 'SUCCESS',
|
||||
message: id ? 'receive details updated' : 'wallet attached for receives'
|
||||
message: id ? 'wallet details updated' : 'wallet attached'
|
||||
}
|
||||
}),
|
||||
models.walletLog.create({
|
||||
|
@ -712,18 +795,18 @@ async function upsertWallet (
|
|||
userId: me.id,
|
||||
wallet: wallet.type,
|
||||
level: enabled ? 'SUCCESS' : 'INFO',
|
||||
message: enabled ? 'receives enabled' : 'receives disabled'
|
||||
message: enabled ? 'wallet enabled' : 'wallet disabled'
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
await models.$transaction(txs)
|
||||
return true
|
||||
const [upsertedWallet] = await models.$transaction(txs)
|
||||
return upsertedWallet
|
||||
}
|
||||
|
||||
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
|
||||
|
@ -807,7 +890,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 = {
|
||||
|
|
|
@ -18,6 +18,7 @@ import admin from './admin'
|
|||
import blockHeight from './blockHeight'
|
||||
import chainFee from './chainFee'
|
||||
import paidAction from './paidAction'
|
||||
import vault from './vault'
|
||||
|
||||
const common = gql`
|
||||
type Query {
|
||||
|
@ -38,4 +39,4 @@ const common = gql`
|
|||
`
|
||||
|
||||
export default [common, user, item, itemForward, message, wallet, lnurl, notifications, invite,
|
||||
sub, upload, growth, rewards, referrals, price, admin, blockHeight, chainFee, paidAction]
|
||||
sub, upload, growth, rewards, referrals, price, admin, blockHeight, chainFee, paidAction, vault]
|
||||
|
|
|
@ -183,6 +183,8 @@ export default gql`
|
|||
autoWithdrawThreshold: Int
|
||||
autoWithdrawMaxFeePercent: Float
|
||||
autoWithdrawMaxFeeTotal: Int
|
||||
vaultKeyHash: String
|
||||
walletsUpdatedAt: Date
|
||||
}
|
||||
|
||||
type UserOptional {
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
import { gql } from 'graphql-tag'
|
||||
|
||||
export default gql`
|
||||
type VaultEntry {
|
||||
id: ID!
|
||||
key: String!
|
||||
iv: String!
|
||||
value: String!
|
||||
createdAt: Date!
|
||||
updatedAt: Date!
|
||||
}
|
||||
|
||||
input VaultEntryInput {
|
||||
key: String!
|
||||
iv: String!
|
||||
value: String!
|
||||
walletId: ID
|
||||
}
|
||||
|
||||
extend type Query {
|
||||
getVaultEntry(key: String!): VaultEntry
|
||||
getVaultEntries(keysFilter: [String!]): [VaultEntry!]!
|
||||
}
|
||||
|
||||
extend type Mutation {
|
||||
clearVault: Boolean
|
||||
updateVaultKey(entries: [VaultEntryInput!]!, hash: String!): Boolean
|
||||
}
|
||||
`
|
|
@ -1,8 +1,7 @@
|
|||
import { gql } from 'graphql-tag'
|
||||
import { fieldToGqlArg, generateResolverName, generateTypeDefName } from '@/lib/wallet'
|
||||
|
||||
import { fieldToGqlArg, fieldToGqlArgOptional, generateResolverName, generateTypeDefName } from '@/wallets/graphql'
|
||||
import { isServerField } from '@/wallets/common'
|
||||
import walletDefs from 'wallets/server'
|
||||
import { isServerField } from 'wallets'
|
||||
|
||||
function injectTypeDefs (typeDefs) {
|
||||
const injected = [rawTypeDefs(), mutationTypeDefs()]
|
||||
|
@ -14,12 +13,13 @@ function mutationTypeDefs () {
|
|||
|
||||
const typeDefs = walletDefs.map((w) => {
|
||||
let args = 'id: ID, '
|
||||
args += w.fields
|
||||
const serverFields = w.fields
|
||||
.filter(isServerField)
|
||||
.map(fieldToGqlArg).join(', ')
|
||||
args += ', settings: AutowithdrawSettings!, priorityOnly: Boolean'
|
||||
.map(fieldToGqlArgOptional)
|
||||
if (serverFields.length > 0) args += serverFields.join(', ') + ','
|
||||
args += 'enabled: Boolean, priority: Int, vaultEntries: [VaultEntryInput!], settings: AutowithdrawSettings, validateLightning: Boolean'
|
||||
const resolverName = generateResolverName(w.walletField)
|
||||
const typeDef = `${resolverName}(${args}): Boolean`
|
||||
const typeDef = `${resolverName}(${args}): Wallet`
|
||||
console.log(typeDef)
|
||||
return typeDef
|
||||
})
|
||||
|
@ -33,11 +33,15 @@ function rawTypeDefs () {
|
|||
console.group('injected GraphQL type defs:')
|
||||
|
||||
const typeDefs = walletDefs.map((w) => {
|
||||
const args = w.fields
|
||||
let args = w.fields
|
||||
.filter(isServerField)
|
||||
.map(fieldToGqlArg)
|
||||
.map(s => ' ' + s)
|
||||
.join('\n')
|
||||
if (!args) {
|
||||
// add a placeholder arg so the type is not empty
|
||||
args = ' _empty: Boolean'
|
||||
}
|
||||
const typeDefName = generateTypeDefName(w.walletType)
|
||||
const typeDef = `type ${typeDefName} {\n${args}\n}`
|
||||
console.log(typeDef)
|
||||
|
@ -63,7 +67,7 @@ const typeDefs = `
|
|||
numBolt11s: Int!
|
||||
connectAddress: String!
|
||||
walletHistory(cursor: String, inc: String): History
|
||||
wallets: [Wallet!]!
|
||||
wallets(includeReceivers: Boolean, includeSenders: Boolean, onlyEnabled: Boolean, prioritySort: String): [Wallet!]!
|
||||
wallet(id: ID!): Wallet
|
||||
walletByType(type: String!): Wallet
|
||||
walletLogs(type: String, from: String, to: String, cursor: String): WalletLog!
|
||||
|
@ -77,23 +81,24 @@ const typeDefs = `
|
|||
dropBolt11(id: ID): Withdrawl
|
||||
removeWallet(id: ID!): Boolean
|
||||
deleteWalletLogs(wallet: String): Boolean
|
||||
setWalletPriority(id: ID!, priority: Int!): Boolean
|
||||
}
|
||||
|
||||
type Wallet {
|
||||
id: ID!
|
||||
createdAt: Date!
|
||||
updatedAt: Date!
|
||||
type: String!
|
||||
enabled: Boolean!
|
||||
priority: Int!
|
||||
wallet: WalletDetails!
|
||||
vaultEntries: [VaultEntry!]!
|
||||
}
|
||||
|
||||
input AutowithdrawSettings {
|
||||
autoWithdrawThreshold: Int!
|
||||
autoWithdrawMaxFeePercent: Float!
|
||||
autoWithdrawMaxFeeTotal: Int!
|
||||
priority: Int
|
||||
enabled: Boolean
|
||||
}
|
||||
|
||||
type Invoice {
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
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'
|
||||
|
||||
function autoWithdrawThreshold ({ me }) {
|
||||
|
@ -18,7 +17,7 @@ export function autowithdrawInitial ({ me }) {
|
|||
}
|
||||
}
|
||||
|
||||
export function AutowithdrawSettings ({ wallet }) {
|
||||
export function AutowithdrawSettings () {
|
||||
const { me } = useMe()
|
||||
const threshold = autoWithdrawThreshold({ me })
|
||||
|
||||
|
@ -28,16 +27,8 @@ export function AutowithdrawSettings ({ wallet }) {
|
|||
setSendThreshold(Math.max(Math.floor(threshold / 10), 1))
|
||||
}, [autoWithdrawThreshold])
|
||||
|
||||
const isClient = useIsClient()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Checkbox
|
||||
disabled={isClient && !wallet.isConfigured}
|
||||
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>
|
||||
|
|
|
@ -7,6 +7,7 @@ import { WELCOME_BANNER_MUTATION } from '@/fragments/users'
|
|||
import { useToast } from '@/components/toast'
|
||||
import { BALANCE_LIMIT_MSATS } from '@/lib/constants'
|
||||
import { msatsToSats, numWithUnits } from '@/lib/format'
|
||||
import Link from 'next/link'
|
||||
|
||||
export function WelcomeBanner ({ Banner }) {
|
||||
const { me } = useMe()
|
||||
|
@ -122,18 +123,17 @@ export function WalletLimitBanner () {
|
|||
)
|
||||
}
|
||||
|
||||
export function WalletSecurityBanner () {
|
||||
export function WalletSecurityBanner ({ isActive }) {
|
||||
return (
|
||||
<Alert className={styles.banner} key='info' variant='warning'>
|
||||
<Alert.Heading>
|
||||
Wallet Security Disclaimer
|
||||
Gunslingin' Safety Tips
|
||||
</Alert.Heading>
|
||||
<p className='mb-1'>
|
||||
Your wallet's credentials for spending are stored in the browser and never go to the server.
|
||||
However, you should definitely <strong>set a budget in your wallet</strong> if you can.
|
||||
<p className='mb-3 line-height-md'>
|
||||
Listen up, pardner! Put a limit on yer spendin' wallet or hook up a wallet that's only for Stacker News. It'll keep them varmints from cleanin' out yer whole goldmine if they rustle up yer wallet.
|
||||
</p>
|
||||
<p>
|
||||
Also, for the time being, you will have to reenter your credentials on other devices.
|
||||
<p className='line-height-md'>
|
||||
Your spending wallet's credentials are never sent to our servers in plain text. To sync across devices, <Alert.Link as={Link} href='/settings/passphrase'>enable device sync in your settings</Alert.Link>.
|
||||
</p>
|
||||
</Alert>
|
||||
)
|
||||
|
|
|
@ -4,6 +4,6 @@ import Button from 'react-bootstrap/Button'
|
|||
export default function CancelButton ({ onClick }) {
|
||||
const router = useRouter()
|
||||
return (
|
||||
<Button className='me-4 text-muted nav-link fw-bold' variant='link' onClick={onClick || (() => router.back())}>cancel</Button>
|
||||
<Button className='me-3 text-muted nav-link fw-bold' variant='link' onClick={onClick || (() => router.back())}>cancel</Button>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -93,7 +93,10 @@ function sortHelper (a, b) {
|
|||
}
|
||||
}
|
||||
|
||||
export function FeeButtonProvider ({ baseLineItems = {}, useRemoteLineItems = () => null, children }) {
|
||||
const DEFAULT_BASE_LINE_ITEMS = {}
|
||||
const DEFAULT_USE_REMOTE_LINE_ITEMS = () => null
|
||||
|
||||
export function FeeButtonProvider ({ baseLineItems = DEFAULT_BASE_LINE_ITEMS, useRemoteLineItems = DEFAULT_USE_REMOTE_LINE_ITEMS, children }) {
|
||||
const [lineItems, setLineItems] = useState({})
|
||||
const [disabled, setDisabled] = useState(false)
|
||||
const { me } = useMe()
|
||||
|
|
|
@ -33,6 +33,13 @@ import EyeClose from '@/svgs/eye-close-line.svg'
|
|||
import Info from './info'
|
||||
import { useMe } from './me'
|
||||
import classNames from 'classnames'
|
||||
import Clipboard from '@/svgs/clipboard-line.svg'
|
||||
import QrIcon from '@/svgs/qr-code-line.svg'
|
||||
import QrScanIcon from '@/svgs/qr-scan-line.svg'
|
||||
import { useShowModal } from './modal'
|
||||
import { QRCodeSVG } from 'qrcode.react'
|
||||
import { Scanner } from '@yudiel/react-qr-scanner'
|
||||
import { qrImageSettings } from './qr'
|
||||
import { useIsClient } from './use-client'
|
||||
|
||||
export class SessionRequiredError extends Error {
|
||||
|
@ -70,31 +77,41 @@ export function SubmitButton ({
|
|||
)
|
||||
}
|
||||
|
||||
export function CopyInput (props) {
|
||||
function CopyButton ({ value, icon, ...props }) {
|
||||
const toaster = useToast()
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleClick = async () => {
|
||||
const handleClick = useCallback(async () => {
|
||||
try {
|
||||
await copy(props.placeholder)
|
||||
await copy(value)
|
||||
toaster.success('copied')
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
} catch (err) {
|
||||
toaster.danger('failed to copy')
|
||||
}
|
||||
}, [toaster, value])
|
||||
|
||||
if (icon) {
|
||||
return (
|
||||
<InputGroup.Text style={{ cursor: 'pointer' }} onClick={handleClick}>
|
||||
<Clipboard height={20} width={20} />
|
||||
</InputGroup.Text>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Button className={styles.appendButton} {...props} onClick={handleClick}>
|
||||
{copied ? <Thumb width={18} height={18} /> : 'copy'}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export function CopyInput (props) {
|
||||
return (
|
||||
<Input
|
||||
onClick={handleClick}
|
||||
append={
|
||||
<Button
|
||||
className={styles.appendButton}
|
||||
size={props.size}
|
||||
onClick={handleClick}
|
||||
>{copied ? <Thumb width={18} height={18} /> : 'copy'}
|
||||
</Button>
|
||||
<CopyButton value={props.placeholder} size={props.size} />
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
|
@ -713,10 +730,11 @@ export function InputUserSuggest ({
|
|||
)
|
||||
}
|
||||
|
||||
export function Input ({ label, groupClassName, ...props }) {
|
||||
export function Input ({ label, groupClassName, under, ...props }) {
|
||||
return (
|
||||
<FormGroup label={label} className={groupClassName}>
|
||||
<InputInner {...props} />
|
||||
{under}
|
||||
</FormGroup>
|
||||
)
|
||||
}
|
||||
|
@ -1054,7 +1072,7 @@ function Client (Component) {
|
|||
// where the initial value is not available on first render.
|
||||
// Example: value is stored in localStorage which is fetched
|
||||
// after first render using an useEffect hook.
|
||||
const [,, helpers] = useField(props)
|
||||
const [,, helpers] = props.noForm ? [{}, {}, {}] : useField(props)
|
||||
|
||||
useEffect(() => {
|
||||
initialValue && helpers.setValue(initialValue)
|
||||
|
@ -1072,24 +1090,121 @@ function PasswordHider ({ onClick, showPass }) {
|
|||
>
|
||||
{!showPass
|
||||
? <Eye
|
||||
fill='var(--bs-body-color)' height={20} width={20}
|
||||
fill='var(--bs-body-color)' height={16} width={16}
|
||||
/>
|
||||
: <EyeClose
|
||||
fill='var(--bs-body-color)' height={20} width={20}
|
||||
fill='var(--bs-body-color)' height={16} width={16}
|
||||
/>}
|
||||
</InputGroup.Text>
|
||||
)
|
||||
}
|
||||
|
||||
export function PasswordInput ({ newPass, ...props }) {
|
||||
function QrPassword ({ value }) {
|
||||
const showModal = useShowModal()
|
||||
const toaster = useToast()
|
||||
|
||||
const showQr = useCallback(() => {
|
||||
showModal(close => (
|
||||
<div>
|
||||
<p className='line-height-md text-muted'>Import this passphrase into another device by navigating to device sync settings and scanning this QR code</p>
|
||||
<div className='d-block p-3 mx-auto' style={{ background: 'white', maxWidth: '300px' }}>
|
||||
<QRCodeSVG className='h-auto mw-100' value={value} size={300} imageSettings={qrImageSettings} />
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}, [toaster, value, showModal])
|
||||
|
||||
return (
|
||||
<>
|
||||
<InputGroup.Text
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={showQr}
|
||||
>
|
||||
<QrIcon height={16} width={16} />
|
||||
</InputGroup.Text>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function PasswordScanner ({ onScan }) {
|
||||
const showModal = useShowModal()
|
||||
const toaster = useToast()
|
||||
|
||||
return (
|
||||
<InputGroup.Text
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
showModal(onClose => {
|
||||
return (
|
||||
<Scanner
|
||||
formats={['qr_code']}
|
||||
onScan={([{ rawValue: result }]) => {
|
||||
onScan(result)
|
||||
onClose()
|
||||
}}
|
||||
styles={{
|
||||
video: {
|
||||
aspectRatio: '1 / 1'
|
||||
}
|
||||
}}
|
||||
onError={(error) => {
|
||||
if (error instanceof DOMException) {
|
||||
console.log(error)
|
||||
} else {
|
||||
toaster.danger('qr scan: ' + error?.message || error?.toString?.())
|
||||
}
|
||||
onClose()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}}
|
||||
>
|
||||
<QrScanIcon
|
||||
height={20} width={20} fill='var(--bs-body-color)'
|
||||
/>
|
||||
</InputGroup.Text>
|
||||
)
|
||||
}
|
||||
|
||||
export function PasswordInput ({ newPass, qr, copy, readOnly, append, value, ...props }) {
|
||||
const [showPass, setShowPass] = useState(false)
|
||||
const [field, helpers] = props.noForm ? [{ value }, {}, {}] : useField(props)
|
||||
|
||||
const Append = useMemo(() => {
|
||||
return (
|
||||
<>
|
||||
<PasswordHider showPass={showPass} onClick={() => setShowPass(!showPass)} />
|
||||
{copy && (
|
||||
<CopyButton icon value={field?.value} />
|
||||
)}
|
||||
{qr && (readOnly
|
||||
? <QrPassword value={field?.value} />
|
||||
: <PasswordScanner
|
||||
onScan={v => helpers.setValue(v)}
|
||||
/>)}
|
||||
{append}
|
||||
</>
|
||||
)
|
||||
}, [showPass, copy, field?.value, qr, readOnly, append])
|
||||
|
||||
const maskedValue = !showPass && props.as === 'textarea' ? field?.value?.replace(/./g, '•') : field?.value
|
||||
|
||||
return (
|
||||
<ClientInput
|
||||
{...props}
|
||||
className={styles.passwordInput}
|
||||
type={showPass ? 'text' : 'password'}
|
||||
autoComplete={newPass ? 'new-password' : 'current-password'}
|
||||
append={<PasswordHider showPass={showPass} onClick={() => setShowPass(!showPass)} />}
|
||||
readOnly={readOnly}
|
||||
append={props.as === 'textarea' ? undefined : Append}
|
||||
value={maskedValue}
|
||||
under={props.as === 'textarea'
|
||||
? (
|
||||
<div className='mt-2 d-flex justify-content-end' style={{ gap: '8px' }}>
|
||||
{Append}
|
||||
</div>)
|
||||
: undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -2,6 +2,10 @@
|
|||
border-top-left-radius: 0;
|
||||
}
|
||||
|
||||
textarea.passwordInput {
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.markdownInput textarea {
|
||||
margin-top: -1px;
|
||||
font-size: 94%;
|
||||
|
@ -69,4 +73,16 @@
|
|||
0% {
|
||||
opacity: 42%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div.qr {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
div.qr>svg {
|
||||
justify-self: center;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
padding: 1rem;
|
||||
background-color: white;
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@ import Bolt11Info from './bolt11-info'
|
|||
import { useQuery } from '@apollo/client'
|
||||
import { INVOICE } from '@/fragments/wallet'
|
||||
import { FAST_POLL_INTERVAL, SSR } from '@/lib/constants'
|
||||
import { NoAttachedWalletError } from './payment'
|
||||
import { NoAttachedWalletError } from '@/wallets/errors'
|
||||
import ItemJob from './item-job'
|
||||
import Item from './item'
|
||||
import { CommentFlat } from './comment'
|
||||
|
|
|
@ -13,7 +13,7 @@ import { usePaidMutation } from './use-paid-mutation'
|
|||
import { ACT_MUTATION } from '@/fragments/paidAction'
|
||||
import { meAnonSats } from '@/lib/apollo'
|
||||
import { BoostItemInput } from './adv-post-form'
|
||||
import { useWallet } from '../wallets'
|
||||
import { useWallet } from '@/wallets/index'
|
||||
|
||||
const defaultTips = [100, 1000, 10_000, 100_000]
|
||||
|
||||
|
|
|
@ -10,7 +10,10 @@ import { LIMIT } from '@/lib/cursor'
|
|||
import ItemFull from './item-full'
|
||||
import { useData } from './use-data'
|
||||
|
||||
export default function Items ({ ssrData, variables = {}, query, destructureData, rank, noMoreText, Footer, filter = () => true }) {
|
||||
const DEFAULT_FILTER = () => true
|
||||
const DEFAULT_VARIABLES = {}
|
||||
|
||||
export default function Items ({ ssrData, variables = DEFAULT_VARIABLES, query, destructureData, rank, noMoreText, Footer, filter = DEFAULT_FILTER }) {
|
||||
const { data, fetchMore } = useQuery(query || SUB_ITEMS, { variables })
|
||||
const Foooter = Footer || MoreFooter
|
||||
const dat = useData(data, ssrData)
|
||||
|
|
|
@ -45,13 +45,20 @@ export default function useModal () {
|
|||
}, [getCurrentContent, forceUpdate])
|
||||
|
||||
// this is called on every navigation due to below useEffect
|
||||
const onClose = useCallback(() => {
|
||||
const onClose = useCallback((options) => {
|
||||
if (options?.back) {
|
||||
for (let i = 0; i < options.back; i++) {
|
||||
onBack()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
while (modalStack.current.length) {
|
||||
getCurrentContent()?.options?.onClose?.()
|
||||
modalStack.current.pop()
|
||||
}
|
||||
forceUpdate()
|
||||
}, [])
|
||||
}, [onBack])
|
||||
|
||||
const router = useRouter()
|
||||
useEffect(() => {
|
||||
|
@ -90,7 +97,7 @@ export default function useModal () {
|
|||
{overflow}
|
||||
</ActionDropdown>
|
||||
</div>}
|
||||
{modalStack.current.length > 1 ? <div className='modal-btn modal-back' onClick={onBack}><BackArrow width={18} height={18} className='fill-white' /></div> : null}
|
||||
{modalStack.current.length > 1 ? <div className='modal-btn modal-back' onClick={onBack}><BackArrow width={18} height={18} /></div> : null}
|
||||
<div className={'modal-btn modal-close ' + className} onClick={onClose}>X</div>
|
||||
</div>
|
||||
<Modal.Body className={className}>
|
||||
|
|
|
@ -22,7 +22,7 @@ import SearchIcon from '../../svgs/search-line.svg'
|
|||
import classNames from 'classnames'
|
||||
import SnIcon from '@/svgs/sn.svg'
|
||||
import { useHasNewNotes } from '../use-has-new-notes'
|
||||
import { useWallets } from 'wallets'
|
||||
import { useWallets } from '@/wallets/index'
|
||||
import SwitchAccountList, { useAccounts } from '@/components/account'
|
||||
import { useShowModal } from '@/components/modal'
|
||||
|
||||
|
@ -263,7 +263,7 @@ export default function LoginButton () {
|
|||
|
||||
function LogoutObstacle ({ onClose }) {
|
||||
const { registration: swRegistration, togglePushSubscription } = useServiceWorker()
|
||||
const wallets = useWallets()
|
||||
const { removeLocalWallets } = useWallets()
|
||||
const { multiAuthSignout } = useAccounts()
|
||||
|
||||
return (
|
||||
|
@ -292,7 +292,7 @@ function LogoutObstacle ({ onClose }) {
|
|||
await togglePushSubscription().catch(console.error)
|
||||
}
|
||||
|
||||
await wallets.resetClient().catch(console.error)
|
||||
removeLocalWallets()
|
||||
|
||||
await signOut({ callbackUrl: '/' })
|
||||
}}
|
||||
|
|
|
@ -1,35 +1,13 @@
|
|||
import { useCallback, useMemo } from 'react'
|
||||
import { useMe } from './me'
|
||||
import { gql, useApolloClient, useMutation } from '@apollo/client'
|
||||
import { useWallet } from 'wallets'
|
||||
import { useWallet } from '@/wallets/index'
|
||||
import { FAST_POLL_INTERVAL, JIT_INVOICE_TIMEOUT_MS } from '@/lib/constants'
|
||||
import { INVOICE } from '@/fragments/wallet'
|
||||
import Invoice from '@/components/invoice'
|
||||
import { useFeeButton } from './fee-button'
|
||||
import { useShowModal } from './modal'
|
||||
|
||||
export class InvoiceCanceledError extends Error {
|
||||
constructor (hash, actionError) {
|
||||
super(actionError ?? `invoice canceled: ${hash}`)
|
||||
this.name = 'InvoiceCanceledError'
|
||||
this.hash = hash
|
||||
this.actionError = actionError
|
||||
}
|
||||
}
|
||||
|
||||
export class NoAttachedWalletError extends Error {
|
||||
constructor () {
|
||||
super('no attached wallet found')
|
||||
this.name = 'NoAttachedWalletError'
|
||||
}
|
||||
}
|
||||
|
||||
export class InvoiceExpiredError extends Error {
|
||||
constructor (hash) {
|
||||
super(`invoice expired: ${hash}`)
|
||||
this.name = 'InvoiceExpiredError'
|
||||
}
|
||||
}
|
||||
import { InvoiceCanceledError, NoAttachedWalletError, InvoiceExpiredError } from '@/wallets/errors'
|
||||
|
||||
export const useInvoice = () => {
|
||||
const client = useApolloClient()
|
||||
|
|
|
@ -2,9 +2,18 @@ import { QRCodeSVG } from 'qrcode.react'
|
|||
import { CopyInput, InputSkeleton } from './form'
|
||||
import InvoiceStatus from './invoice-status'
|
||||
import { useEffect } from 'react'
|
||||
import { useWallet } from 'wallets'
|
||||
import { useWallet } from '@/wallets/index'
|
||||
import Bolt11Info from './bolt11-info'
|
||||
|
||||
export const qrImageSettings = {
|
||||
src: 'data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 256 256\'%3E%3Cpath fill-rule=\'evenodd\' d=\'m46.7 96.4 37.858 53.837-71.787 62.934L117.5 155.4l-40.075-52.854 49.412-59.492Zm156.35 41.546-49.416-58.509-34.909 116.771 44.25-67.358 58.509 59.25L241.4 47.725Z\'/%3E%3C/svg%3E',
|
||||
x: undefined,
|
||||
y: undefined,
|
||||
height: 60,
|
||||
width: 60,
|
||||
excavate: true
|
||||
}
|
||||
|
||||
export default function Qr ({ asIs, value, useWallet: automated, statusVariant, description, status }) {
|
||||
const qrValue = asIs ? value : 'lightning:' + value.toUpperCase()
|
||||
const wallet = useWallet()
|
||||
|
@ -26,14 +35,7 @@ export default function Qr ({ asIs, value, useWallet: automated, statusVariant,
|
|||
<>
|
||||
<a className='d-block p-3 mx-auto' style={{ background: 'white', maxWidth: '300px' }} href={qrValue}>
|
||||
<QRCodeSVG
|
||||
className='h-auto mw-100' value={qrValue} size={300} imageSettings={{
|
||||
src: 'data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 256 256\'%3E%3Cpath fill-rule=\'evenodd\' d=\'m46.7 96.4 37.858 53.837-71.787 62.934L117.5 155.4l-40.075-52.854 49.412-59.492Zm156.35 41.546-49.416-58.509-34.909 116.771 44.25-67.358 58.509 59.25L241.4 47.725Z\'/%3E%3C/svg%3E',
|
||||
x: undefined,
|
||||
y: undefined,
|
||||
height: 60,
|
||||
width: 60,
|
||||
excavate: true
|
||||
}}
|
||||
className='h-auto mw-100' value={qrValue} size={300} imageSettings={qrImageSettings}
|
||||
/>
|
||||
</a>
|
||||
{description && <div className='mt-1 text-center text-muted'>{description}</div>}
|
||||
|
|
|
@ -15,7 +15,11 @@ export function SubSelectInitial ({ sub }) {
|
|||
}
|
||||
}
|
||||
|
||||
export function useSubs ({ prependSubs = [], sub, filterSubs = () => true, appendSubs = [] }) {
|
||||
const DEFAULT_PREPEND_SUBS = []
|
||||
const DEFAULT_APPEND_SUBS = []
|
||||
const DEFAULT_FILTER_SUBS = () => true
|
||||
|
||||
export function useSubs ({ prependSubs = DEFAULT_PREPEND_SUBS, sub, filterSubs = DEFAULT_FILTER_SUBS, appendSubs = DEFAULT_APPEND_SUBS }) {
|
||||
const { data } = useQuery(SUBS, SSR
|
||||
? {}
|
||||
: {
|
||||
|
|
|
@ -17,7 +17,9 @@ export function debounce (fn, time) {
|
|||
}
|
||||
}
|
||||
|
||||
export default function useDebounceCallback (fn, time, deps = []) {
|
||||
const DEFAULT_DEPS = []
|
||||
|
||||
export default function useDebounceCallback (fn, time, deps = DEFAULT_DEPS) {
|
||||
const [args, setArgs] = useState([])
|
||||
const memoFn = useCallback(fn, deps)
|
||||
useNoInitialEffect(debounce(() => memoFn(...args), time), [memoFn, time, args])
|
||||
|
|
|
@ -1,6 +1,14 @@
|
|||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
|
||||
function useIndexedDB (dbName, storeName, version = 1, indices = []) {
|
||||
export function getDbName (userId, name) {
|
||||
return `app:storage:${userId ?? ''}${name ? `:${name}` : ''}`
|
||||
}
|
||||
|
||||
const DEFAULT_OPTIONS = { keyPath: 'id', autoIncrement: true }
|
||||
const DEFAULT_INDICES = []
|
||||
const DEFAULT_VERSION = 1
|
||||
|
||||
function useIndexedDB ({ dbName, storeName, options = DEFAULT_OPTIONS, indices = DEFAULT_INDICES, version = DEFAULT_VERSION }) {
|
||||
const [db, setDb] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [notSupported, setNotSupported] = useState(false)
|
||||
|
@ -24,7 +32,7 @@ function useIndexedDB (dbName, storeName, version = 1, indices = []) {
|
|||
} catch (error) {
|
||||
handleError(error)
|
||||
}
|
||||
}, [storeName, handleError])
|
||||
}, [storeName, handleError, operationQueue])
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true
|
||||
|
@ -58,7 +66,7 @@ function useIndexedDB (dbName, storeName, version = 1, indices = []) {
|
|||
request.onupgradeneeded = (event) => {
|
||||
const database = event.target.result
|
||||
try {
|
||||
const store = database.createObjectStore(storeName, { keyPath: 'id', autoIncrement: true })
|
||||
const store = database.createObjectStore(storeName, options)
|
||||
|
||||
indices.forEach(index => {
|
||||
store.createIndex(index.name, index.keyPath, index.options)
|
||||
|
@ -77,7 +85,7 @@ function useIndexedDB (dbName, storeName, version = 1, indices = []) {
|
|||
db.close()
|
||||
}
|
||||
}
|
||||
}, [dbName, storeName, version, indices, handleError, processQueue])
|
||||
}, [dbName, storeName, version, indices, options, handleError, processQueue])
|
||||
|
||||
const queueOperation = useCallback((operation) => {
|
||||
if (notSupported) {
|
||||
|
@ -141,20 +149,15 @@ function useIndexedDB (dbName, storeName, version = 1, indices = []) {
|
|||
})
|
||||
}, [queueOperation, storeName])
|
||||
|
||||
const update = useCallback((key, value) => {
|
||||
const set = useCallback((key, value) => {
|
||||
return queueOperation((db) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(storeName, 'readwrite')
|
||||
const store = transaction.objectStore(storeName)
|
||||
const request = store.get(key)
|
||||
const request = store.put(value, key)
|
||||
|
||||
request.onerror = () => reject(new Error('Error updating data'))
|
||||
request.onsuccess = () => {
|
||||
const updatedValue = { ...request.result, ...value }
|
||||
const updateRequest = store.put(updatedValue)
|
||||
updateRequest.onerror = () => reject(new Error('Error updating data'))
|
||||
updateRequest.onsuccess = () => resolve(updateRequest.result)
|
||||
}
|
||||
request.onerror = () => reject(new Error('Error setting data'))
|
||||
request.onsuccess = () => resolve(request.result)
|
||||
})
|
||||
})
|
||||
}, [queueOperation, storeName])
|
||||
|
@ -286,7 +289,7 @@ function useIndexedDB (dbName, storeName, version = 1, indices = []) {
|
|||
})
|
||||
}, [queueOperation, storeName])
|
||||
|
||||
return { add, get, getAll, update, remove, clear, getByIndex, getAllByIndex, getPage, error, notSupported }
|
||||
return { add, get, getAll, set, remove, clear, getByIndex, getAllByIndex, getPage, error, notSupported }
|
||||
}
|
||||
|
||||
export default useIndexedDB
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { useApolloClient, useLazyQuery, useMutation } from '@apollo/client'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { InvoiceCanceledError, InvoiceExpiredError, useInvoice, useQrPayment, useWalletPayment } from './payment'
|
||||
import { useInvoice, useQrPayment, useWalletPayment } from './payment'
|
||||
import { InvoiceCanceledError, InvoiceExpiredError } from '@/wallets/errors'
|
||||
import { GET_PAID_ACTION } from '@/fragments/paidAction'
|
||||
|
||||
/*
|
||||
|
|
|
@ -140,7 +140,9 @@ function UserHidden ({ rank, Embellish }) {
|
|||
)
|
||||
}
|
||||
|
||||
export function ListUsers ({ users, rank, statComps = seperate(STAT_COMPONENTS, Seperator), Embellish, nymActionDropdown }) {
|
||||
const DEFAULT_STAT_COMPONENTS = seperate(STAT_COMPONENTS, Seperator)
|
||||
|
||||
export function ListUsers ({ users, rank, statComps = DEFAULT_STAT_COMPONENTS, Embellish, nymActionDropdown }) {
|
||||
return (
|
||||
<div className={styles.grid}>
|
||||
{users.map((user, i) => (
|
||||
|
@ -155,7 +157,7 @@ export function ListUsers ({ users, rank, statComps = seperate(STAT_COMPONENTS,
|
|||
export default function UserList ({ ssrData, query, variables, destructureData, rank, footer = true, nymActionDropdown, statCompsProp }) {
|
||||
const { data, fetchMore } = useQuery(query, { variables })
|
||||
const dat = useData(data, ssrData)
|
||||
const [statComps, setStatComps] = useState(seperate(STAT_COMPONENTS, Seperator))
|
||||
const [statComps, setStatComps] = useState(DEFAULT_STAT_COMPONENTS)
|
||||
|
||||
useEffect(() => {
|
||||
// shift the stat we are sorting by to the front
|
||||
|
|
|
@ -0,0 +1,160 @@
|
|||
import { useMutation, useQuery } from '@apollo/client'
|
||||
import { useMe } from '../me'
|
||||
import { useToast } from '../toast'
|
||||
import useIndexedDB, { getDbName } from '../use-indexeddb'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { E_VAULT_KEY_EXISTS } from '@/lib/error'
|
||||
import { CLEAR_VAULT, GET_VAULT_ENTRIES, UPDATE_VAULT_KEY } from '@/fragments/vault'
|
||||
import { toHex } from '@/lib/hex'
|
||||
import { decryptValue, encryptValue } from './use-vault'
|
||||
|
||||
const useImperativeQuery = (query) => {
|
||||
const { refetch } = useQuery(query, { skip: true })
|
||||
|
||||
const imperativelyCallQuery = (variables) => {
|
||||
return refetch(variables)
|
||||
}
|
||||
|
||||
return imperativelyCallQuery
|
||||
}
|
||||
|
||||
export function useVaultConfigurator ({ onVaultKeySet, beforeDisconnectVault } = {}) {
|
||||
const { me } = useMe()
|
||||
const toaster = useToast()
|
||||
const idbConfig = useMemo(() => ({ dbName: getDbName(me?.id, 'vault'), storeName: 'vault', options: {} }), [me?.id])
|
||||
const { set, get, remove } = useIndexedDB(idbConfig)
|
||||
const [updateVaultKey] = useMutation(UPDATE_VAULT_KEY)
|
||||
const getVaultEntries = useImperativeQuery(GET_VAULT_ENTRIES)
|
||||
const [key, setKey] = useState(null)
|
||||
const [keyHash, setKeyHash] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!me) return
|
||||
(async () => {
|
||||
try {
|
||||
let localVaultKey = await get('key')
|
||||
const localKeyHash = me?.privates?.vaultKeyHash || keyHash
|
||||
if (localVaultKey?.hash && localVaultKey?.hash !== localKeyHash) {
|
||||
// If the hash stored in the server does not match the hash of the local key,
|
||||
// we can tell that the key is outdated (reset by another device or other reasons)
|
||||
// in this case we clear the local key and let the user re-enter the passphrase
|
||||
console.log('vault key hash mismatch, clearing local key', localVaultKey?.hash, '!=', localKeyHash)
|
||||
localVaultKey = null
|
||||
await remove('key')
|
||||
}
|
||||
setKey(localVaultKey)
|
||||
} catch (e) {
|
||||
// toaster?.danger('error loading vault configuration ' + e.message)
|
||||
}
|
||||
})()
|
||||
}, [me?.privates?.vaultKeyHash, keyHash, get, remove, setKey])
|
||||
|
||||
// clear vault: remove everything and reset the key
|
||||
const [clearVault] = useMutation(CLEAR_VAULT, {
|
||||
onCompleted: async () => {
|
||||
try {
|
||||
await remove('key')
|
||||
setKey(null)
|
||||
setKeyHash(null)
|
||||
} catch (e) {
|
||||
toaster.danger('error clearing vault ' + e.message)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const disconnectVault = useCallback(async () => {
|
||||
beforeDisconnectVault?.()
|
||||
await remove('key')
|
||||
setKey(null)
|
||||
setKeyHash(null)
|
||||
}, [remove, setKey, setKeyHash])
|
||||
|
||||
// initialize the vault and set a vault key
|
||||
const setVaultKey = useCallback(async (passphrase) => {
|
||||
try {
|
||||
const oldKeyValue = await get('key')
|
||||
const vaultKey = await deriveKey(me.id, passphrase)
|
||||
const { data } = await getVaultEntries()
|
||||
|
||||
const encrypt = async value => {
|
||||
return await encryptValue(vaultKey.key, value)
|
||||
}
|
||||
|
||||
const entries = []
|
||||
if (oldKeyValue?.key) {
|
||||
for (const { key, iv, value } of data.getVaultEntries) {
|
||||
const plainValue = await decryptValue(oldKeyValue.key, { iv, value })
|
||||
entries.push({ key, ...await encrypt(plainValue) })
|
||||
}
|
||||
}
|
||||
|
||||
await updateVaultKey({
|
||||
variables: { entries, hash: vaultKey.hash },
|
||||
onError: (error) => {
|
||||
const errorCode = error.graphQLErrors[0]?.extensions?.code
|
||||
if (errorCode === E_VAULT_KEY_EXISTS) {
|
||||
throw new Error('wrong passphrase')
|
||||
}
|
||||
toaster.danger(error.graphQLErrors[0].message)
|
||||
}
|
||||
})
|
||||
|
||||
setKey(vaultKey)
|
||||
setKeyHash(vaultKey.hash)
|
||||
await set('key', vaultKey)
|
||||
onVaultKeySet?.(encrypt).catch(console.error)
|
||||
} catch (e) {
|
||||
toaster.danger(e.message)
|
||||
}
|
||||
}, [getVaultEntries, updateVaultKey, set, get, remove, onVaultKeySet])
|
||||
|
||||
return { key, setVaultKey, clearVault, disconnectVault }
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a key to be used for the vault encryption
|
||||
* @param {string | number} userId - the id of the user (used for salting)
|
||||
* @param {string} passphrase - the passphrase to derive the key from
|
||||
* @returns {Promise<{key: CryptoKey, hash: string, extractable: boolean}>} an un-extractable CryptoKey and its hash
|
||||
*/
|
||||
async function deriveKey (userId, passphrase) {
|
||||
const enc = new TextEncoder()
|
||||
|
||||
const keyMaterial = await window.crypto.subtle.importKey(
|
||||
'raw',
|
||||
enc.encode(passphrase),
|
||||
{ name: 'PBKDF2' },
|
||||
false,
|
||||
['deriveKey']
|
||||
)
|
||||
|
||||
const key = await window.crypto.subtle.deriveKey(
|
||||
{
|
||||
name: 'PBKDF2',
|
||||
salt: enc.encode(`stacker${userId}`),
|
||||
// 600,000 iterations is recommended by OWASP
|
||||
// see https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2
|
||||
iterations: 600_000,
|
||||
hash: 'SHA-256'
|
||||
},
|
||||
keyMaterial,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
true,
|
||||
['encrypt', 'decrypt']
|
||||
)
|
||||
|
||||
const rawKey = await window.crypto.subtle.exportKey('raw', key)
|
||||
const hash = toHex(await window.crypto.subtle.digest('SHA-256', rawKey))
|
||||
const unextractableKey = await window.crypto.subtle.importKey(
|
||||
'raw',
|
||||
rawKey,
|
||||
{ name: 'AES-GCM' },
|
||||
false,
|
||||
['encrypt', 'decrypt']
|
||||
)
|
||||
|
||||
return {
|
||||
key: unextractableKey,
|
||||
hash
|
||||
}
|
||||
}
|
|
@ -0,0 +1,64 @@
|
|||
import { useCallback } from 'react'
|
||||
import { useVaultConfigurator } from './use-vault-configurator'
|
||||
import { fromHex, toHex } from '@/lib/hex'
|
||||
|
||||
export default function useVault () {
|
||||
const { key } = useVaultConfigurator()
|
||||
|
||||
const encrypt = useCallback(async (value) => {
|
||||
if (!key) throw new Error('no vault key set')
|
||||
return await encryptValue(key.key, value)
|
||||
}, [key])
|
||||
|
||||
const decrypt = useCallback(async ({ iv, value }) => {
|
||||
if (!key) throw new Error('no vault key set')
|
||||
return await decryptValue(key.key, { iv, value })
|
||||
}, [key])
|
||||
|
||||
return { encrypt, decrypt, isActive: !!key?.key }
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt data using AES-GCM
|
||||
* @param {CryptoKey} sharedKey - the key to use for encryption
|
||||
* @param {Object} value - the value to encrypt
|
||||
* @returns {Promise<Object>} an object with iv and value properties, can be passed to decryptValue to get the original data back
|
||||
*/
|
||||
export async function encryptValue (sharedKey, value) {
|
||||
// random IVs are _really_ important in GCM: reusing the IV once can lead to catastrophic failure
|
||||
// see https://crypto.stackexchange.com/questions/26790/how-bad-it-is-using-the-same-iv-twice-with-aes-gcm
|
||||
// 12 bytes (96 bits) is the recommended IV size for AES-GCM
|
||||
const iv = window.crypto.getRandomValues(new Uint8Array(12))
|
||||
const encoded = new TextEncoder().encode(JSON.stringify(value))
|
||||
const encrypted = await window.crypto.subtle.encrypt(
|
||||
{
|
||||
name: 'AES-GCM',
|
||||
iv
|
||||
},
|
||||
sharedKey,
|
||||
encoded
|
||||
)
|
||||
return {
|
||||
iv: toHex(iv.buffer),
|
||||
value: toHex(encrypted)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt data using AES-GCM
|
||||
* @param {CryptoKey} sharedKey - the key to use for decryption
|
||||
* @param {Object} encryptedValue - the encrypted value as returned by encryptValue
|
||||
* @returns {Promise<Object>} the original unencrypted data
|
||||
*/
|
||||
export async function decryptValue (sharedKey, { iv, value }) {
|
||||
const decrypted = await window.crypto.subtle.decrypt(
|
||||
{
|
||||
name: 'AES-GCM',
|
||||
iv: fromHex(iv)
|
||||
},
|
||||
sharedKey,
|
||||
fromHex(value)
|
||||
)
|
||||
const decoded = new TextDecoder().decode(decrypted)
|
||||
return JSON.parse(decoded)
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
import { Button } from 'react-bootstrap'
|
||||
import CancelButton from './cancel-button'
|
||||
import { SubmitButton } from './form'
|
||||
import { isConfigured } from '@/wallets/common'
|
||||
|
||||
export default function WalletButtonBar ({
|
||||
wallet, disable,
|
||||
|
@ -10,12 +11,12 @@ export default function WalletButtonBar ({
|
|||
return (
|
||||
<div className={`mt-3 ${className}`}>
|
||||
<div className='d-flex justify-content-between'>
|
||||
{wallet.hasConfig && wallet.isConfigured &&
|
||||
{isConfigured(wallet) &&
|
||||
<Button onClick={onDelete} variant='grey-medium'>{deleteText}</Button>}
|
||||
{children}
|
||||
<div className='d-flex align-items-center ms-auto'>
|
||||
{hasCancel && <CancelButton onClick={onCancel} />}
|
||||
<SubmitButton variant='primary' disabled={disable}>{wallet.isConfigured ? editText : createText}</SubmitButton>
|
||||
<SubmitButton variant='primary' disabled={disable}>{isConfigured(wallet) ? editText : createText}</SubmitButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -3,26 +3,18 @@ import styles from '@/styles/wallet.module.css'
|
|||
import Plug from '@/svgs/plug.svg'
|
||||
import Gear from '@/svgs/settings-5-fill.svg'
|
||||
import Link from 'next/link'
|
||||
import { Status } from 'wallets'
|
||||
import { Status, isConfigured } from '@/wallets/common'
|
||||
import DraggableIcon from '@/svgs/draggable.svg'
|
||||
|
||||
export default function WalletCard ({ wallet, draggable, onDragStart, onDragEnter, onDragEnd, onTouchStart, sourceIndex, targetIndex, index }) {
|
||||
const { card: { title, badges } } = wallet
|
||||
const { card: { title, badges } } = wallet.def
|
||||
|
||||
let indicator = styles.disabled
|
||||
switch (wallet.status) {
|
||||
case Status.Enabled:
|
||||
case true:
|
||||
indicator = styles.success
|
||||
break
|
||||
case Status.Locked:
|
||||
indicator = styles.warning
|
||||
break
|
||||
case Status.Error:
|
||||
indicator = styles.error
|
||||
break
|
||||
case Status.Initialized:
|
||||
case false:
|
||||
default:
|
||||
indicator = styles.disabled
|
||||
break
|
||||
}
|
||||
|
@ -65,9 +57,9 @@ export default function WalletCard ({ wallet, draggable, onDragStart, onDragEnte
|
|||
})}
|
||||
</Card.Subtitle>
|
||||
</Card.Body>
|
||||
<Link href={`/settings/wallets/${wallet.name}`}>
|
||||
<Link href={`/settings/wallets/${wallet.def.name}`}>
|
||||
<Card.Footer className={styles.attach}>
|
||||
{wallet.isConfigured
|
||||
{isConfigured(wallet)
|
||||
? <>configure<Gear width={14} height={14} /></>
|
||||
: <>attach<Plug width={14} height={14} /></>}
|
||||
</Card.Footer>
|
||||
|
|
|
@ -5,10 +5,10 @@ import { Button } from 'react-bootstrap'
|
|||
import { useToast } from './toast'
|
||||
import { useShowModal } from './modal'
|
||||
import { WALLET_LOGS } from '@/fragments/wallet'
|
||||
import { getWalletByType } from 'wallets'
|
||||
import { getWalletByType } from '@/wallets/common'
|
||||
import { gql, useLazyQuery, useMutation } from '@apollo/client'
|
||||
import { useMe } from './me'
|
||||
import useIndexedDB from './use-indexeddb'
|
||||
import useIndexedDB, { getDbName } from './use-indexeddb'
|
||||
import { SSR } from '@/lib/constants'
|
||||
|
||||
export function WalletLogs ({ wallet, embedded }) {
|
||||
|
@ -86,11 +86,17 @@ const INDICES = [
|
|||
{ name: 'wallet_ts', keyPath: ['wallet', 'ts'] }
|
||||
]
|
||||
|
||||
function getWalletLogDbName (userId) {
|
||||
return getDbName(userId)
|
||||
}
|
||||
|
||||
function useWalletLogDB () {
|
||||
const { me } = useMe()
|
||||
const dbName = `app:storage${me ? `:${me.id}` : ''}`
|
||||
const idbStoreName = 'wallet_logs'
|
||||
const { add, getPage, clear, error, notSupported } = useIndexedDB(dbName, idbStoreName, 1, INDICES)
|
||||
// memoize the idb config to avoid re-creating it on every render
|
||||
const idbConfig = useMemo(() =>
|
||||
({ dbName: getWalletLogDbName(me?.id), storeName: 'wallet_logs', indices: INDICES }), [me?.id])
|
||||
const { add, getPage, clear, error, notSupported } = useIndexedDB(idbConfig)
|
||||
|
||||
return { add, getPage, clear, error, notSupported }
|
||||
}
|
||||
|
||||
|
@ -125,8 +131,8 @@ export function useWalletLogger (wallet, setLogs) {
|
|||
)
|
||||
|
||||
const deleteLogs = useCallback(async (wallet, options) => {
|
||||
if ((!wallet || wallet.walletType) && !options?.clientOnly) {
|
||||
await deleteServerWalletLogs({ variables: { wallet: wallet?.walletType } })
|
||||
if ((!wallet || wallet.def.walletType) && !options?.clientOnly) {
|
||||
await deleteServerWalletLogs({ variables: { wallet: wallet?.def.walletType } })
|
||||
}
|
||||
if (!wallet || wallet.sendPayment) {
|
||||
try {
|
||||
|
@ -145,7 +151,7 @@ export function useWalletLogger (wallet, setLogs) {
|
|||
|
||||
const log = useCallback(level => message => {
|
||||
if (!wallet) {
|
||||
console.error('cannot log: no wallet set')
|
||||
// console.error('cannot log: no wallet set')
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -157,13 +163,13 @@ export function useWalletLogger (wallet, setLogs) {
|
|||
ok: (...message) => log('ok')(message.join(' ')),
|
||||
info: (...message) => log('info')(message.join(' ')),
|
||||
error: (...message) => log('error')(message.join(' '))
|
||||
}), [log, wallet?.name])
|
||||
}), [log])
|
||||
|
||||
return { logger, deleteLogs }
|
||||
}
|
||||
|
||||
function tag (wallet) {
|
||||
return wallet?.shortName || wallet?.name
|
||||
function tag (walletDef) {
|
||||
return walletDef.shortName || walletDef.name
|
||||
}
|
||||
|
||||
export function useWalletLogs (wallet, initialPage = 1, logsPerPage = 10) {
|
||||
|
@ -177,24 +183,24 @@ export function useWalletLogs (wallet, initialPage = 1, logsPerPage = 10) {
|
|||
const { getPage, error, notSupported } = useWalletLogDB()
|
||||
const [getWalletLogs] = useLazyQuery(WALLET_LOGS, SSR ? {} : { fetchPolicy: 'cache-and-network' })
|
||||
|
||||
const loadLogsPage = useCallback(async (page, pageSize, wallet) => {
|
||||
const loadLogsPage = useCallback(async (page, pageSize, walletDef) => {
|
||||
try {
|
||||
let result = { data: [], hasMore: false }
|
||||
if (notSupported) {
|
||||
console.log('cannot get client wallet logs: indexeddb not supported')
|
||||
} else {
|
||||
const indexName = wallet ? 'wallet_ts' : 'ts'
|
||||
const query = wallet ? window.IDBKeyRange.bound([tag(wallet), -Infinity], [tag(wallet), Infinity]) : null
|
||||
const indexName = walletDef ? 'wallet_ts' : 'ts'
|
||||
const query = walletDef ? window.IDBKeyRange.bound([tag(walletDef), -Infinity], [tag(walletDef), Infinity]) : null
|
||||
|
||||
result = await getPage(page, pageSize, indexName, query, 'prev')
|
||||
// no walletType means we're using the local IDB
|
||||
if (wallet && !wallet.walletType) {
|
||||
if (!walletDef?.walletType) {
|
||||
return result
|
||||
}
|
||||
}
|
||||
const { data } = await getWalletLogs({
|
||||
variables: {
|
||||
type: wallet?.walletType,
|
||||
type: walletDef.walletType,
|
||||
// if it client logs has more, page based on it's range
|
||||
from: result?.data[result.data.length - 1]?.ts && result.hasMore ? String(result.data[result.data.length - 1].ts) : null,
|
||||
// if we have a cursor (this isn't the first page), page based on it's range
|
||||
|
@ -225,28 +231,28 @@ export function useWalletLogs (wallet, initialPage = 1, logsPerPage = 10) {
|
|||
const loadMore = useCallback(async () => {
|
||||
if (hasMore) {
|
||||
setLoading(true)
|
||||
const result = await loadLogsPage(page + 1, logsPerPage, wallet)
|
||||
const result = await loadLogsPage(page + 1, logsPerPage, wallet?.def)
|
||||
setLogs(prevLogs => [...prevLogs, ...result.data])
|
||||
setHasMore(result.hasMore)
|
||||
setTotal(result.total)
|
||||
setPage(prevPage => prevPage + 1)
|
||||
setLoading(false)
|
||||
}
|
||||
}, [loadLogsPage, page, logsPerPage, wallet, hasMore])
|
||||
}, [loadLogsPage, page, logsPerPage, wallet?.def, hasMore])
|
||||
|
||||
const loadLogs = useCallback(async () => {
|
||||
setLoading(true)
|
||||
const result = await loadLogsPage(1, logsPerPage, wallet)
|
||||
const result = await loadLogsPage(1, logsPerPage, wallet?.def)
|
||||
setLogs(result.data)
|
||||
setHasMore(result.hasMore)
|
||||
setTotal(result.total)
|
||||
setPage(1)
|
||||
setLoading(false)
|
||||
}, [wallet, loadLogsPage])
|
||||
}, [wallet?.def, loadLogsPage])
|
||||
|
||||
useEffect(() => {
|
||||
loadLogs()
|
||||
}, [wallet])
|
||||
}, [wallet?.def])
|
||||
|
||||
return { logs, hasMore, total, loadMore, loadLogs, setLogs, loading }
|
||||
}
|
||||
|
|
|
@ -48,6 +48,8 @@ ${STREAK_FIELDS}
|
|||
upvotePopover
|
||||
wildWestMode
|
||||
disableFreebies
|
||||
vaultKeyHash
|
||||
walletsUpdatedAt
|
||||
}
|
||||
optional {
|
||||
isContributor
|
||||
|
|
|
@ -0,0 +1,44 @@
|
|||
import { gql } from '@apollo/client'
|
||||
|
||||
export const VAULT_ENTRY_FIELDS = gql`
|
||||
fragment VaultEntryFields on VaultEntry {
|
||||
id
|
||||
key
|
||||
iv
|
||||
value
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
`
|
||||
|
||||
export const GET_VAULT_ENTRY = gql`
|
||||
${VAULT_ENTRY_FIELDS}
|
||||
query GetVaultEntry(
|
||||
$key: String!
|
||||
) {
|
||||
getVaultEntry(key: $key) {
|
||||
...VaultEntryFields
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const GET_VAULT_ENTRIES = gql`
|
||||
${VAULT_ENTRY_FIELDS}
|
||||
query GetVaultEntries {
|
||||
getVaultEntries {
|
||||
...VaultEntryFields
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const CLEAR_VAULT = gql`
|
||||
mutation ClearVault {
|
||||
clearVault
|
||||
}
|
||||
`
|
||||
|
||||
export const UPDATE_VAULT_KEY = gql`
|
||||
mutation updateVaultKey($entries: [VaultEntryInput!]!, $hash: String!) {
|
||||
updateVaultKey(entries: $entries, hash: $hash)
|
||||
}
|
||||
`
|
|
@ -1,5 +1,6 @@
|
|||
import { gql } from '@apollo/client'
|
||||
import { ITEM_FULL_FIELDS } from './items'
|
||||
import { VAULT_ENTRY_FIELDS } from './vault'
|
||||
|
||||
export const INVOICE_FIELDS = gql`
|
||||
fragment InvoiceFields on Invoice {
|
||||
|
@ -106,100 +107,76 @@ mutation removeWallet($id: ID!) {
|
|||
removeWallet(id: $id)
|
||||
}
|
||||
`
|
||||
|
||||
// XXX [WALLET] this needs to be updated if another server wallet is added
|
||||
export const WALLET_FIELDS = gql`
|
||||
${VAULT_ENTRY_FIELDS}
|
||||
fragment WalletFields on Wallet {
|
||||
id
|
||||
priority
|
||||
type
|
||||
updatedAt
|
||||
enabled
|
||||
vaultEntries {
|
||||
...VaultEntryFields
|
||||
}
|
||||
wallet {
|
||||
__typename
|
||||
... on WalletLightningAddress {
|
||||
address
|
||||
}
|
||||
... on WalletLnd {
|
||||
socket
|
||||
macaroon
|
||||
cert
|
||||
}
|
||||
... on WalletCln {
|
||||
socket
|
||||
rune
|
||||
cert
|
||||
}
|
||||
... on WalletLnbits {
|
||||
url
|
||||
invoiceKey
|
||||
}
|
||||
... on WalletNwc {
|
||||
nwcUrlRecv
|
||||
}
|
||||
... on WalletPhoenixd {
|
||||
url
|
||||
secondaryPassword
|
||||
}
|
||||
... on WalletBlink {
|
||||
apiKeyRecv
|
||||
currencyRecv
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const WALLET = gql`
|
||||
${WALLET_FIELDS}
|
||||
query Wallet($id: ID!) {
|
||||
wallet(id: $id) {
|
||||
id
|
||||
createdAt
|
||||
priority
|
||||
type
|
||||
wallet {
|
||||
__typename
|
||||
... on WalletLightningAddress {
|
||||
address
|
||||
}
|
||||
... on WalletLnd {
|
||||
socket
|
||||
macaroon
|
||||
cert
|
||||
}
|
||||
... on WalletCln {
|
||||
socket
|
||||
rune
|
||||
cert
|
||||
}
|
||||
... on WalletLnbits {
|
||||
url
|
||||
invoiceKey
|
||||
}
|
||||
... on WalletNwc {
|
||||
nwcUrlRecv
|
||||
}
|
||||
... on WalletPhoenixd {
|
||||
url
|
||||
secondaryPassword
|
||||
}
|
||||
... on WalletBlink {
|
||||
apiKeyRecv
|
||||
currencyRecv
|
||||
}
|
||||
}
|
||||
...WalletFields
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
// XXX [WALLET] this needs to be updated if another server wallet is added
|
||||
export const WALLET_BY_TYPE = gql`
|
||||
${WALLET_FIELDS}
|
||||
query WalletByType($type: String!) {
|
||||
walletByType(type: $type) {
|
||||
id
|
||||
createdAt
|
||||
enabled
|
||||
priority
|
||||
type
|
||||
wallet {
|
||||
__typename
|
||||
... on WalletLightningAddress {
|
||||
address
|
||||
}
|
||||
... on WalletLnd {
|
||||
socket
|
||||
macaroon
|
||||
cert
|
||||
}
|
||||
... on WalletCln {
|
||||
socket
|
||||
rune
|
||||
cert
|
||||
}
|
||||
... on WalletLnbits {
|
||||
url
|
||||
invoiceKey
|
||||
}
|
||||
... on WalletNwc {
|
||||
nwcUrlRecv
|
||||
}
|
||||
... on WalletPhoenixd {
|
||||
url
|
||||
secondaryPassword
|
||||
}
|
||||
... on WalletBlink {
|
||||
apiKeyRecv
|
||||
currencyRecv
|
||||
}
|
||||
}
|
||||
...WalletFields
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const WALLETS = gql`
|
||||
${WALLET_FIELDS}
|
||||
query Wallets {
|
||||
wallets {
|
||||
id
|
||||
priority
|
||||
type
|
||||
...WalletFields
|
||||
}
|
||||
}
|
||||
`
|
||||
|
@ -214,7 +191,13 @@ export const WALLET_LOGS = gql`
|
|||
wallet
|
||||
level
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const SET_WALLET_PRIORITY = gql`
|
||||
mutation SetWalletPriority($id: ID!, $priority: Int!) {
|
||||
setWalletPriority(id: $id, priority: $priority)
|
||||
}
|
||||
`
|
||||
|
|
|
@ -99,6 +99,13 @@ function getClient (uri) {
|
|||
Fact: {
|
||||
keyFields: ['id', 'type']
|
||||
},
|
||||
Wallet: {
|
||||
fields: {
|
||||
vaultEntries: {
|
||||
replace: true
|
||||
}
|
||||
}
|
||||
},
|
||||
Query: {
|
||||
fields: {
|
||||
sub: {
|
||||
|
|
|
@ -3,6 +3,7 @@ import { GraphQLError } from 'graphql'
|
|||
export const E_FORBIDDEN = 'E_FORBIDDEN'
|
||||
export const E_UNAUTHENTICATED = 'E_UNAUTHENTICATED'
|
||||
export const E_BAD_INPUT = 'E_BAD_INPUT'
|
||||
export const E_VAULT_KEY_EXISTS = 'E_VAULT_KEY_EXISTS'
|
||||
|
||||
export class GqlAuthorizationError extends GraphQLError {
|
||||
constructor (message) {
|
||||
|
@ -17,7 +18,7 @@ export class GqlAuthenticationError extends GraphQLError {
|
|||
}
|
||||
|
||||
export class GqlInputError extends GraphQLError {
|
||||
constructor (message) {
|
||||
super(message, { extensions: { code: E_BAD_INPUT } })
|
||||
constructor (message, code) {
|
||||
super(message, { extensions: { code: code || E_BAD_INPUT } })
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
/**
|
||||
* Convert a buffer to a hex string
|
||||
* @param {*} buffer - the buffer to convert
|
||||
* @returns {string} - the hex string
|
||||
*/
|
||||
export function toHex (buffer) {
|
||||
const byteArray = new Uint8Array(buffer)
|
||||
const hexString = Array.from(byteArray, byte => byte.toString(16).padStart(2, '0')).join('')
|
||||
return hexString
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a hex string to a buffer
|
||||
* @param {string} hex - the hex string to convert
|
||||
* @returns {ArrayBuffer} - the buffer
|
||||
*/
|
||||
export function fromHex (hex) {
|
||||
const byteArray = new Uint8Array(hex.match(/.{1,2}/g).map(byte => parseInt(byte, 16)))
|
||||
return byteArray.buffer
|
||||
}
|
443
lib/validate.js
443
lib/validate.js
|
@ -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 (wallet, data) {
|
||||
if (typeof wallet.fieldValidation === 'function') {
|
||||
return await formikValidate(wallet.fieldValidation, data)
|
||||
} else {
|
||||
return await ssValidate(wallet.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,44 @@ 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 vaultEntrySchema = key => object({
|
||||
key: string().required('required').matches(key, `expected ${key}`),
|
||||
iv: string().required('required').hex().length(24, 'must be 24 characters long'),
|
||||
value: string().required('required').hex().min(2).max(1024 * 10)
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
}),
|
||||
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 function bountySchema (args) {
|
||||
return object({
|
||||
|
@ -663,165 +475,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().shape({
|
||||
apiKey: string()
|
||||
.matches(/^blink_[A-Za-z0-9]+$/, { message: 'must match pattern blink_A-Za-z0-9' })
|
||||
.when(['apiKeyRecv'], ([apiKeyRecv], schema) => {
|
||||
if (!apiKeyRecv) return schema.required('required if api key for receiving not set')
|
||||
return schema.test({
|
||||
test: apiKey => apiKey !== apiKeyRecv,
|
||||
message: 'api key for sending cannot be the same as for receiving'
|
||||
})
|
||||
}),
|
||||
apiKeyRecv: string()
|
||||
.matches(/^blink_[A-Za-z0-9]+$/, { message: 'must match pattern blink_A-Za-z0-9' })
|
||||
.when(['apiKey'], ([apiKey], schema) => {
|
||||
if (!apiKey) return schema.required('required if api key for sending not set')
|
||||
return schema.test({
|
||||
test: apiKeyRecv => apiKeyRecv !== apiKey,
|
||||
message: 'api key for receiving cannot be the same as for sending'
|
||||
})
|
||||
}),
|
||||
currency: string()
|
||||
.transform(value => value ? value.toUpperCase() : 'BTC')
|
||||
.oneOf(['USD', 'BTC'], 'must be BTC or USD'),
|
||||
currencyRecv: string()
|
||||
.transform(value => value ? value.toUpperCase() : 'BTC')
|
||||
.oneOf(['BTC'], 'must be BTC'),
|
||||
...autowithdrawSchemaMembers
|
||||
}, ['apiKey', 'apiKeyRecv'])
|
||||
|
||||
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()
|
||||
})
|
||||
|
@ -863,3 +516,23 @@ export const toNumber = (x, min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE
|
|||
}
|
||||
|
||||
export const toPositiveNumber = (x) => toNumber(x, 0)
|
||||
|
||||
export const deviceSyncSchema = object().shape({
|
||||
passphrase: string().required('required')
|
||||
.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 < 12) {
|
||||
return context.createError({ message: 'needs at least 12 words' })
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
|
|
@ -1,17 +0,0 @@
|
|||
export function fieldToGqlArg (field) {
|
||||
let arg = `${field.name}: String`
|
||||
if (!field.optional) {
|
||||
arg += '!'
|
||||
}
|
||||
return arg
|
||||
}
|
||||
|
||||
export function generateResolverName (walletField) {
|
||||
const capitalized = walletField[0].toUpperCase() + walletField.slice(1)
|
||||
return `upsert${capitalized}`
|
||||
}
|
||||
|
||||
export function generateTypeDefName (walletType) {
|
||||
const PascalCase = walletType.split('_').map(s => s[0].toUpperCase() + s.slice(1).toLowerCase()).join('')
|
||||
return `Wallet${PascalCase}`
|
||||
}
|
|
@ -0,0 +1,200 @@
|
|||
import { addMethod, string, mixed, array } from 'yup'
|
||||
import { parseNwcUrl } from './url'
|
||||
import { NOSTR_PUBKEY_HEX } from './nostr'
|
||||
import { ensureB64, HEX_REGEX } from './format'
|
||||
export * from 'yup'
|
||||
|
||||
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: (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 {
|
||||
string().matches(/^nostr\+?walletconnect:\/\//, { message: 'must start with nostr+walletconnect://' }).validateSync(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')
|
||||
}
|
||||
string().required('pubkey required').trim().matches(NOSTR_PUBKEY_HEX, 'pubkey must be 64 hex chars').validateSync(walletPubkey)
|
||||
string().required('relay url required').trim().wss('relay must use wss://').validateSync(relayUrl)
|
||||
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
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
addMethod(array, 'equalto', function equals (
|
||||
{ required, optional },
|
||||
message
|
||||
) {
|
||||
return this.test({
|
||||
name: 'equalto',
|
||||
message: message || `${this.path} has invalid values`,
|
||||
test: function (items = []) {
|
||||
if (items.length < required.length) {
|
||||
return this.createError({ message: `Expected ${this.path} to be at least ${required.length} items, but got ${items.length}` })
|
||||
}
|
||||
if (items.length > required.length + optional.length) {
|
||||
return this.createError({ message: `Expected ${this.path} to be at most ${required.length + optional.length} items, but got ${items.length}` })
|
||||
}
|
||||
const remainingRequiredSchemas = [...required]
|
||||
const remainingOptionalSchemas = [...optional]
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const requiredIndex = remainingRequiredSchemas.findIndex(schema => schema.isValidSync(items[i], { strict: true }))
|
||||
if (requiredIndex === -1) {
|
||||
const optionalIndex = remainingOptionalSchemas.findIndex(schema => schema.isValidSync(items[i], { strict: true }))
|
||||
if (optionalIndex === -1) {
|
||||
return this.createError({ message: `${this.path}[${i}] has invalid value` })
|
||||
}
|
||||
remainingOptionalSchemas.splice(optionalIndex, 1)
|
||||
continue
|
||||
}
|
||||
remainingRequiredSchemas.splice(requiredIndex, 1)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
})
|
||||
})
|
|
@ -51,7 +51,7 @@
|
|||
"mdast-util-gfm": "^3.0.0",
|
||||
"mdast-util-to-string": "^4.0.0",
|
||||
"micromark-extension-gfm": "^3.0.0",
|
||||
"next": "^14.2.15",
|
||||
"next": "^14.2.16",
|
||||
"next-auth": "^4.24.8",
|
||||
"next-plausible": "^3.12.2",
|
||||
"next-seo": "^6.6.0",
|
||||
|
@ -4124,9 +4124,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "14.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.15.tgz",
|
||||
"integrity": "sha512-S1qaj25Wru2dUpcIZMjxeMVSwkt8BK4dmWHHiBuRstcIyOsMapqT4A4jSB6onvqeygkSSmOkyny9VVx8JIGamQ=="
|
||||
"version": "14.2.16",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.16.tgz",
|
||||
"integrity": "sha512-fLrX5TfJzHCbnZ9YUSnGW63tMV3L4nSfhgOQ0iCcX21Pt+VSTDuaLsSuL8J/2XAiVA5AnzvXDpf6pMs60QxOag=="
|
||||
},
|
||||
"node_modules/@next/eslint-plugin-next": {
|
||||
"version": "14.2.15",
|
||||
|
@ -4184,9 +4184,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-arm64": {
|
||||
"version": "14.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.15.tgz",
|
||||
"integrity": "sha512-Rvh7KU9hOUBnZ9TJ28n2Oa7dD9cvDBKua9IKx7cfQQ0GoYUwg9ig31O2oMwH3wm+pE3IkAQ67ZobPfEgurPZIA==",
|
||||
"version": "14.2.16",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.16.tgz",
|
||||
"integrity": "sha512-uFT34QojYkf0+nn6MEZ4gIWQ5aqGF11uIZ1HSxG+cSbj+Mg3+tYm8qXYd3dKN5jqKUm5rBVvf1PBRO/MeQ6rxw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
@ -4199,9 +4199,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-x64": {
|
||||
"version": "14.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.15.tgz",
|
||||
"integrity": "sha512-5TGyjFcf8ampZP3e+FyCax5zFVHi+Oe7sZyaKOngsqyaNEpOgkKB3sqmymkZfowy3ufGA/tUgDPPxpQx931lHg==",
|
||||
"version": "14.2.16",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.16.tgz",
|
||||
"integrity": "sha512-mCecsFkYezem0QiZlg2bau3Xul77VxUD38b/auAjohMA22G9KTJneUYMv78vWoCCFkleFAhY1NIvbyjj1ncG9g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
@ -4214,9 +4214,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||
"version": "14.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.15.tgz",
|
||||
"integrity": "sha512-3Bwv4oc08ONiQ3FiOLKT72Q+ndEMyLNsc/D3qnLMbtUYTQAmkx9E/JRu0DBpHxNddBmNT5hxz1mYBphJ3mfrrw==",
|
||||
"version": "14.2.16",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.16.tgz",
|
||||
"integrity": "sha512-yhkNA36+ECTC91KSyZcgWgKrYIyDnXZj8PqtJ+c2pMvj45xf7y/HrgI17hLdrcYamLfVt7pBaJUMxADtPaczHA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
@ -4229,9 +4229,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-musl": {
|
||||
"version": "14.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.15.tgz",
|
||||
"integrity": "sha512-k5xf/tg1FBv/M4CMd8S+JL3uV9BnnRmoe7F+GWC3DxkTCD9aewFRH1s5rJ1zkzDa+Do4zyN8qD0N8c84Hu96FQ==",
|
||||
"version": "14.2.16",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.16.tgz",
|
||||
"integrity": "sha512-X2YSyu5RMys8R2lA0yLMCOCtqFOoLxrq2YbazFvcPOE4i/isubYjkh+JCpRmqYfEuCVltvlo+oGfj/b5T2pKUA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
@ -4244,9 +4244,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-gnu": {
|
||||
"version": "14.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.15.tgz",
|
||||
"integrity": "sha512-kE6q38hbrRbKEkkVn62reLXhThLRh6/TvgSP56GkFNhU22TbIrQDEMrO7j0IcQHcew2wfykq8lZyHFabz0oBrA==",
|
||||
"version": "14.2.16",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.16.tgz",
|
||||
"integrity": "sha512-9AGcX7VAkGbc5zTSa+bjQ757tkjr6C/pKS7OK8cX7QEiK6MHIIezBLcQ7gQqbDW2k5yaqba2aDtaBeyyZh1i6Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
@ -4259,9 +4259,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-musl": {
|
||||
"version": "14.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.15.tgz",
|
||||
"integrity": "sha512-PZ5YE9ouy/IdO7QVJeIcyLn/Rc4ml9M2G4y3kCM9MNf1YKvFY4heg3pVa/jQbMro+tP6yc4G2o9LjAz1zxD7tQ==",
|
||||
"version": "14.2.16",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.16.tgz",
|
||||
"integrity": "sha512-Klgeagrdun4WWDaOizdbtIIm8khUDQJ/5cRzdpXHfkbY91LxBXeejL4kbZBrpR/nmgRrQvmz4l3OtttNVkz2Sg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
@ -4274,9 +4274,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||
"version": "14.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.15.tgz",
|
||||
"integrity": "sha512-2raR16703kBvYEQD9HNLyb0/394yfqzmIeyp2nDzcPV4yPjqNUG3ohX6jX00WryXz6s1FXpVhsCo3i+g4RUX+g==",
|
||||
"version": "14.2.16",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.16.tgz",
|
||||
"integrity": "sha512-PwW8A1UC1Y0xIm83G3yFGPiOBftJK4zukTmk7DI1CebyMOoaVpd8aSy7K6GhobzhkjYvqS/QmzcfsWG2Dwizdg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
@ -4289,9 +4289,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-ia32-msvc": {
|
||||
"version": "14.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.15.tgz",
|
||||
"integrity": "sha512-fyTE8cklgkyR1p03kJa5zXEaZ9El+kDNM5A+66+8evQS5e/6v0Gk28LqA0Jet8gKSOyP+OTm/tJHzMlGdQerdQ==",
|
||||
"version": "14.2.16",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.16.tgz",
|
||||
"integrity": "sha512-jhPl3nN0oKEshJBNDAo0etGMzv0j3q3VYorTSFqH1o3rwv1MQRdor27u1zhkgsHPNeY1jxcgyx1ZsCkDD1IHgg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
|
@ -4304,9 +4304,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-x64-msvc": {
|
||||
"version": "14.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.15.tgz",
|
||||
"integrity": "sha512-SzqGbsLsP9OwKNUG9nekShTwhj6JSB9ZLMWQ8g1gG6hdE5gQLncbnbymrwy2yVmH9nikSLYRYxYMFu78Ggp7/g==",
|
||||
"version": "14.2.16",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.16.tgz",
|
||||
"integrity": "sha512-OA7NtfxgirCjfqt+02BqxC3MIgM/JaGjw9tOe4fyZgPsqfseNiMPnCRP44Pfs+Gpo9zPN+SXaFsgP6vk8d571A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
@ -15494,11 +15494,11 @@
|
|||
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "14.2.15",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-14.2.15.tgz",
|
||||
"integrity": "sha512-h9ctmOokpoDphRvMGnwOJAedT6zKhwqyZML9mDtspgf4Rh3Pn7UTYKqePNoDvhsWBAO5GoPNYshnAUGIazVGmw==",
|
||||
"version": "14.2.16",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-14.2.16.tgz",
|
||||
"integrity": "sha512-LcO7WnFu6lYSvCzZoo1dB+IO0xXz5uEv52HF1IUN0IqVTUIZGHuuR10I5efiLadGt+4oZqTcNZyVVEem/TM5nA==",
|
||||
"dependencies": {
|
||||
"@next/env": "14.2.15",
|
||||
"@next/env": "14.2.16",
|
||||
"@swc/helpers": "0.5.5",
|
||||
"busboy": "1.6.0",
|
||||
"caniuse-lite": "^1.0.30001579",
|
||||
|
@ -15513,15 +15513,15 @@
|
|||
"node": ">=18.17.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@next/swc-darwin-arm64": "14.2.15",
|
||||
"@next/swc-darwin-x64": "14.2.15",
|
||||
"@next/swc-linux-arm64-gnu": "14.2.15",
|
||||
"@next/swc-linux-arm64-musl": "14.2.15",
|
||||
"@next/swc-linux-x64-gnu": "14.2.15",
|
||||
"@next/swc-linux-x64-musl": "14.2.15",
|
||||
"@next/swc-win32-arm64-msvc": "14.2.15",
|
||||
"@next/swc-win32-ia32-msvc": "14.2.15",
|
||||
"@next/swc-win32-x64-msvc": "14.2.15"
|
||||
"@next/swc-darwin-arm64": "14.2.16",
|
||||
"@next/swc-darwin-x64": "14.2.16",
|
||||
"@next/swc-linux-arm64-gnu": "14.2.16",
|
||||
"@next/swc-linux-arm64-musl": "14.2.16",
|
||||
"@next/swc-linux-x64-gnu": "14.2.16",
|
||||
"@next/swc-linux-x64-musl": "14.2.16",
|
||||
"@next/swc-win32-arm64-msvc": "14.2.16",
|
||||
"@next/swc-win32-ia32-msvc": "14.2.16",
|
||||
"@next/swc-win32-x64-msvc": "14.2.16"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": "^1.1.0",
|
||||
|
|
|
@ -56,7 +56,7 @@
|
|||
"mdast-util-gfm": "^3.0.0",
|
||||
"mdast-util-to-string": "^4.0.0",
|
||||
"micromark-extension-gfm": "^3.0.0",
|
||||
"next": "^14.2.15",
|
||||
"next": "^14.2.16",
|
||||
"next-auth": "^4.24.8",
|
||||
"next-plausible": "^3.12.2",
|
||||
"next-seo": "^6.6.0",
|
||||
|
|
|
@ -20,8 +20,9 @@ import { LoggerProvider } from '@/components/logger'
|
|||
import { ChainFeeProvider } from '@/components/chain-fee.js'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { HasNewNotesProvider } from '@/components/use-has-new-notes'
|
||||
import WebLnProvider from '@/wallets/webln'
|
||||
import { WebLnProvider } from '@/wallets/webln/client'
|
||||
import { AccountProvider } from '@/components/account'
|
||||
import { WalletsProvider } from '@/wallets/index'
|
||||
|
||||
const PWAPrompt = dynamic(() => import('react-ios-pwa-prompt'), { ssr: false })
|
||||
|
||||
|
@ -104,32 +105,34 @@ export default function MyApp ({ Component, pageProps: { ...props } }) {
|
|||
<PlausibleProvider domain='stacker.news' trackOutboundLinks>
|
||||
<ApolloProvider client={client}>
|
||||
<MeProvider me={me}>
|
||||
<HasNewNotesProvider>
|
||||
<LoggerProvider>
|
||||
<WebLnProvider>
|
||||
<ServiceWorkerProvider>
|
||||
<AccountProvider>
|
||||
<PriceProvider price={price}>
|
||||
<LightningProvider>
|
||||
<ToastProvider>
|
||||
<ShowModalProvider>
|
||||
<BlockHeightProvider blockHeight={blockHeight}>
|
||||
<ChainFeeProvider chainFee={chainFee}>
|
||||
<ErrorBoundary>
|
||||
<Component ssrData={ssrData} {...otherProps} />
|
||||
{!router?.query?.disablePrompt && <PWAPrompt copyBody='This website has app functionality. Add it to your home screen to use it in fullscreen and receive notifications. In Safari:' promptOnVisit={2} />}
|
||||
</ErrorBoundary>
|
||||
</ChainFeeProvider>
|
||||
</BlockHeightProvider>
|
||||
</ShowModalProvider>
|
||||
</ToastProvider>
|
||||
</LightningProvider>
|
||||
</PriceProvider>
|
||||
</AccountProvider>
|
||||
</ServiceWorkerProvider>
|
||||
</WebLnProvider>
|
||||
</LoggerProvider>
|
||||
</HasNewNotesProvider>
|
||||
<WalletsProvider>
|
||||
<HasNewNotesProvider>
|
||||
<LoggerProvider>
|
||||
<WebLnProvider>
|
||||
<ServiceWorkerProvider>
|
||||
<AccountProvider>
|
||||
<PriceProvider price={price}>
|
||||
<LightningProvider>
|
||||
<ToastProvider>
|
||||
<ShowModalProvider>
|
||||
<BlockHeightProvider blockHeight={blockHeight}>
|
||||
<ChainFeeProvider chainFee={chainFee}>
|
||||
<ErrorBoundary>
|
||||
<Component ssrData={ssrData} {...otherProps} />
|
||||
{!router?.query?.disablePrompt && <PWAPrompt copyBody='This website has app functionality. Add it to your home screen to use it in fullscreen and receive notifications. In Safari:' promptOnVisit={2} />}
|
||||
</ErrorBoundary>
|
||||
</ChainFeeProvider>
|
||||
</BlockHeightProvider>
|
||||
</ShowModalProvider>
|
||||
</ToastProvider>
|
||||
</LightningProvider>
|
||||
</PriceProvider>
|
||||
</AccountProvider>
|
||||
</ServiceWorkerProvider>
|
||||
</WebLnProvider>
|
||||
</LoggerProvider>
|
||||
</HasNewNotesProvider>
|
||||
</WalletsProvider>
|
||||
</MeProvider>
|
||||
</ApolloProvider>
|
||||
</PlausibleProvider>
|
||||
|
|
|
@ -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() })
|
||||
|
|
|
@ -77,6 +77,11 @@ export function SettingsHeader () {
|
|||
<Nav.Link eventKey='mutes'>muted stackers</Nav.Link>
|
||||
</Link>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<Link href='/settings/passphrase' passHref legacyBehavior>
|
||||
<Nav.Link eventKey='passphrase'>device sync</Nav.Link>
|
||||
</Link>
|
||||
</Nav.Item>
|
||||
</Nav>
|
||||
</>
|
||||
)
|
||||
|
|
|
@ -0,0 +1,213 @@
|
|||
import { getGetServerSideProps } from '@/api/ssrApollo'
|
||||
import Layout from '@/components/layout'
|
||||
import { SettingsHeader } from '../index'
|
||||
import { useVaultConfigurator } from '@/components/vault/use-vault-configurator'
|
||||
import { useMe } from '@/components/me'
|
||||
import { Button, InputGroup } from 'react-bootstrap'
|
||||
import bip39Words from '@/lib/bip39-words'
|
||||
import { Form, PasswordInput, SubmitButton } from '@/components/form'
|
||||
import { deviceSyncSchema } from '@/lib/validate'
|
||||
import RefreshIcon from '@/svgs/refresh-line.svg'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useToast } from '@/components/toast'
|
||||
import { useWallets } from '@/wallets/index'
|
||||
|
||||
export const getServerSideProps = getGetServerSideProps({ authRequired: true })
|
||||
|
||||
export default function DeviceSync ({ ssrData }) {
|
||||
const { me } = useMe()
|
||||
const { onVaultKeySet, beforeDisconnectVault } = useWallets()
|
||||
const { key, setVaultKey, clearVault, disconnectVault } =
|
||||
useVaultConfigurator({ onVaultKeySet, beforeDisconnectVault })
|
||||
const [passphrase, setPassphrase] = useState()
|
||||
|
||||
const setSeedPassphrase = useCallback(async (passphrase) => {
|
||||
await setVaultKey(passphrase)
|
||||
setPassphrase(passphrase)
|
||||
}, [setVaultKey])
|
||||
|
||||
const enabled = !!me?.privates?.vaultKeyHash
|
||||
const connected = !!key
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className='pb-3 w-100 mt-2'>
|
||||
<SettingsHeader />
|
||||
<small className='line-height-md d-block mt-3' style={{ maxWidth: '600px' }}>
|
||||
<p>
|
||||
Device sync uses end-to-end encryption to securely synchronize your data across devices.
|
||||
|
||||
Your sensitive data remains private and inaccessible to our servers while being synced across all your connected devices using only a passphrase.
|
||||
</p>
|
||||
</small>
|
||||
<div className='mt-4' style={{ maxWidth: '600px' }}>
|
||||
{
|
||||
(connected && passphrase && <Connect passphrase={passphrase} />) ||
|
||||
(connected && <Connected disconnectVault={disconnectVault} />) ||
|
||||
(enabled && <Enabled setVaultKey={setVaultKey} clearVault={clearVault} />) ||
|
||||
<Setup setSeedPassphrase={setSeedPassphrase} />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
function Connect ({ passphrase }) {
|
||||
return (
|
||||
<div>
|
||||
<h2>Connect other devices</h2>
|
||||
<p className='line-height-md'>
|
||||
On your other devices, navigate to device sync settings and enter this exact passphrase.
|
||||
</p>
|
||||
<p className='line-height-md'>
|
||||
<strong>Once you leave this page, this passphrase cannot be shown again.</strong> Connect all the devices you plan to use or write this passphrase down somewhere safe.
|
||||
</p>
|
||||
<PasswordInput
|
||||
label='passphrase'
|
||||
name='passphrase'
|
||||
placeholder=''
|
||||
required
|
||||
autoFocus
|
||||
as='textarea'
|
||||
value={passphrase}
|
||||
noForm
|
||||
rows={3}
|
||||
readOnly
|
||||
copy
|
||||
qr
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Connected ({ disconnectVault }) {
|
||||
return (
|
||||
<div>
|
||||
<h2>Device sync is enabled!</h2>
|
||||
<p>
|
||||
Sensitive data on this device is now securely synced between all connected devices.
|
||||
</p>
|
||||
<p className='text-muted text-sm'>
|
||||
Disconnect to prevent this device from syncing data or to reset your passphrase.
|
||||
</p>
|
||||
<div className='d-flex justify-content-between'>
|
||||
<div className='d-flex align-items-center ms-auto gap-2'>
|
||||
<Button
|
||||
variant='primary'
|
||||
onClick={disconnectVault}
|
||||
>disconnect
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Enabled ({ setVaultKey, clearVault }) {
|
||||
const toaster = useToast()
|
||||
return (
|
||||
<div>
|
||||
<h2>Device sync is enabled</h2>
|
||||
<p className='line-height-md'>
|
||||
This device is not connected. Enter or scan your passphrase to connect. If you've lost your passphrase you may reset it.
|
||||
</p>
|
||||
<Form
|
||||
schema={deviceSyncSchema}
|
||||
initial={{ passphrase: '' }}
|
||||
enableReinitialize
|
||||
onSubmit={async ({ passphrase }) => {
|
||||
try {
|
||||
await setVaultKey(passphrase)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toaster.danger('error setting vault key')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PasswordInput
|
||||
label='passphrase'
|
||||
name='passphrase'
|
||||
placeholder=''
|
||||
required
|
||||
autoFocus
|
||||
as='textarea'
|
||||
rows={3}
|
||||
qr
|
||||
/>
|
||||
<div className='mt-3'>
|
||||
<div className='d-flex justify-content-between align-items-center'>
|
||||
<Button variant='danger' onClick={clearVault}>reset</Button>
|
||||
<SubmitButton variant='primary'>enable</SubmitButton>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const generatePassphrase = (n = 12) => {
|
||||
const rand = new Uint32Array(n)
|
||||
window.crypto.getRandomValues(rand)
|
||||
return Array.from(rand).map(i => bip39Words[i % bip39Words.length]).join(' ')
|
||||
}
|
||||
|
||||
function Setup ({ setSeedPassphrase }) {
|
||||
const [passphrase, setPassphrase] = useState()
|
||||
const toaster = useToast()
|
||||
const newPassphrase = useCallback(() => {
|
||||
setPassphrase(() => generatePassphrase(12))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setPassphrase(() => generatePassphrase(12))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Enable device sync</h2>
|
||||
<p>
|
||||
Enable secure sync of sensitive data (like wallet credentials) between your devices.
|
||||
</p>
|
||||
<p className='text-muted text-sm line-height-md'>
|
||||
After enabled, your passphrase can be used to connect other devices.
|
||||
</p>
|
||||
<Form
|
||||
schema={deviceSyncSchema}
|
||||
initial={{ passphrase }}
|
||||
enableReinitialize
|
||||
onSubmit={async ({ passphrase }) => {
|
||||
try {
|
||||
await setSeedPassphrase(passphrase)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toaster.danger('error setting passphrase')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PasswordInput
|
||||
label='passphrase'
|
||||
name='passphrase'
|
||||
placeholder=''
|
||||
required
|
||||
autoFocus
|
||||
as='textarea'
|
||||
rows={3}
|
||||
readOnly
|
||||
append={
|
||||
<InputGroup.Text style={{ cursor: 'pointer', userSelect: 'none' }} onClick={newPassphrase}>
|
||||
<RefreshIcon width={16} height={16} />
|
||||
</InputGroup.Text>
|
||||
}
|
||||
/>
|
||||
<div className='mt-3'>
|
||||
<div className='d-flex justify-content-between'>
|
||||
<div className='d-flex align-items-center ms-auto gap-2'>
|
||||
<SubmitButton variant='primary'>enable</SubmitButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -5,14 +5,19 @@ import { WalletSecurityBanner } from '@/components/banners'
|
|||
import { WalletLogs } from '@/components/wallet-logger'
|
||||
import { useToast } from '@/components/toast'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useWallet } from 'wallets'
|
||||
import { useWallet } from '@/wallets/index'
|
||||
import Info from '@/components/info'
|
||||
import Text from '@/components/text'
|
||||
import { AutowithdrawSettings } from '@/components/autowithdraw-shared'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useIsClient } from '@/components/use-client'
|
||||
|
||||
const WalletButtonBar = dynamic(() => import('@/components/wallet-buttonbar.js'), { ssr: false })
|
||||
import { autowithdrawInitial, AutowithdrawSettings } from '@/components/autowithdraw-shared'
|
||||
import { canReceive, canSend, isConfigured } from '@/wallets/common'
|
||||
import { SSR } from '@/lib/constants'
|
||||
import WalletButtonBar from '@/components/wallet-buttonbar'
|
||||
import { useWalletConfigurator } from '@/wallets/config'
|
||||
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 })
|
||||
|
||||
|
@ -21,43 +26,65 @@ export default function WalletSettings () {
|
|||
const router = useRouter()
|
||||
const { wallet: name } = router.query
|
||||
const wallet = useWallet(name)
|
||||
const { me } = useMe()
|
||||
const { save, detach } = useWalletConfigurator(wallet)
|
||||
|
||||
const initial = wallet.fields.reduce((acc, field) => {
|
||||
// We still need to run over all wallet fields via reduce
|
||||
// even though we use wallet.config as the initial value
|
||||
// since wallet.config is empty when wallet is not configured.
|
||||
// Also, wallet.config includes general fields like
|
||||
// 'enabled' and 'priority' which are not defined in wallet.fields.
|
||||
return {
|
||||
...acc,
|
||||
[field.name]: wallet.config?.[field.name] || ''
|
||||
const initial = useMemo(() => {
|
||||
const initial = wallet?.def.fields.reduce((acc, field) => {
|
||||
// We still need to run over all wallet fields via reduce
|
||||
// even though we use wallet.config as the initial value
|
||||
// since wallet.config is empty when wallet is not configured.
|
||||
// Also, wallet.config includes general fields like
|
||||
// 'enabled' and 'priority' which are not defined in wallet.fields.
|
||||
return {
|
||||
...acc,
|
||||
[field.name]: wallet?.config?.[field.name] || ''
|
||||
}
|
||||
}, wallet?.config)
|
||||
|
||||
if (wallet?.def.fields.every(f => f.clientOnly)) {
|
||||
return initial
|
||||
}
|
||||
}, wallet.config)
|
||||
|
||||
// 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 }
|
||||
return {
|
||||
...initial,
|
||||
...autowithdrawInitial({ me })
|
||||
}
|
||||
}, [wallet, me])
|
||||
|
||||
const validate = useCallback(async (data) => {
|
||||
try {
|
||||
await validateWallet(wallet.def, data,
|
||||
{ yupOptions: { abortEarly: false }, topLevel: false, skipGenerated: true })
|
||||
} 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>
|
||||
<h2 className='pb-2'>{wallet.card.title}</h2>
|
||||
<h6 className='text-muted text-center pb-3'><Text>{wallet.card.subtitle}</Text></h6>
|
||||
{wallet.canSend && wallet.hasConfig > 0 && <WalletSecurityBanner />}
|
||||
<h2 className='pb-2'>{wallet?.def.card.title}</h2>
|
||||
<h6 className='text-muted text-center pb-3'><Text>{wallet?.def.card.subtitle}</Text></h6>
|
||||
<Form
|
||||
initial={initial}
|
||||
enableReinitialize
|
||||
{...validateProps}
|
||||
validate={validate}
|
||||
onSubmit={async ({ amount, ...values }) => {
|
||||
try {
|
||||
const newConfig = !wallet.isConfigured
|
||||
const newConfig = !isConfigured(wallet)
|
||||
|
||||
// enable wallet if wallet was just configured
|
||||
if (newConfig) {
|
||||
values.enabled = true
|
||||
}
|
||||
|
||||
await wallet.save(values)
|
||||
await save(values, values.enabled)
|
||||
|
||||
toaster.success('saved settings')
|
||||
router.push('/settings/wallets')
|
||||
|
@ -67,23 +94,21 @@ export default function WalletSettings () {
|
|||
}
|
||||
}}
|
||||
>
|
||||
<WalletFields wallet={wallet} />
|
||||
{wallet.walletType
|
||||
? <AutowithdrawSettings wallet={wallet} />
|
||||
: (
|
||||
<CheckboxGroup name='enabled'>
|
||||
<Checkbox
|
||||
disabled={!wallet.isConfigured}
|
||||
label='enabled'
|
||||
name='enabled'
|
||||
groupClassName='mb-0'
|
||||
/>
|
||||
</CheckboxGroup>
|
||||
)}
|
||||
<SendWarningBanner walletDef={wallet.def} />
|
||||
{wallet && <WalletFields 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 {
|
||||
await wallet.delete()
|
||||
await detach()
|
||||
toaster.success('saved settings')
|
||||
router.push('/settings/wallets')
|
||||
} catch (err) {
|
||||
|
@ -95,22 +120,35 @@ export default function WalletSettings () {
|
|||
/>
|
||||
</Form>
|
||||
<div className='mt-3 w-100'>
|
||||
<WalletLogs wallet={wallet} embedded />
|
||||
{wallet && <WalletLogs wallet={wallet} embedded />}
|
||||
</div>
|
||||
</CenterLayout>
|
||||
)
|
||||
}
|
||||
|
||||
function WalletFields ({ wallet: { config, fields, isConfigured } }) {
|
||||
const isClient = useIsClient()
|
||||
function SendWarningBanner ({ walletDef }) {
|
||||
const { values } = useFormikContext()
|
||||
if (!canSend({ def: walletDef, config: values })) return null
|
||||
|
||||
return fields
|
||||
.map(({ name, label = '', type, help, optional, editable, clientOnly, serverOnly, ...props }, i) => {
|
||||
return <WalletSecurityBanner />
|
||||
}
|
||||
|
||||
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, requiredWithout,
|
||||
validate, clientOnly, serverOnly, generated, ...props
|
||||
}, i) => {
|
||||
const rawProps = {
|
||||
...props,
|
||||
name,
|
||||
initialValue: config?.[name],
|
||||
readOnly: isClient && editable === false && ((isConfigured && !!config?.[name]) || !!props.value),
|
||||
initialValue: wallet.config?.[name],
|
||||
readOnly: !SSR && isConfigured(wallet) && editable === false && !!wallet.config?.[name],
|
||||
groupClassName: props.hidden ? 'd-none' : undefined,
|
||||
label: label
|
||||
? (
|
||||
|
|
|
@ -2,68 +2,69 @@ import { getGetServerSideProps } from '@/api/ssrApollo'
|
|||
import Layout from '@/components/layout'
|
||||
import styles from '@/styles/wallet.module.css'
|
||||
import Link from 'next/link'
|
||||
import { useWallets, walletPrioritySort } from 'wallets'
|
||||
import { useState } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useWallets } from '@/wallets/index'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useIsClient } from '@/components/use-client'
|
||||
|
||||
const WalletCard = dynamic(() => import('@/components/wallet-card'), { ssr: false })
|
||||
import WalletCard from '@/components/wallet-card'
|
||||
import { useToast } from '@/components/toast'
|
||||
|
||||
export const getServerSideProps = getGetServerSideProps({ authRequired: true })
|
||||
|
||||
async function reorder (wallets, sourceIndex, targetIndex) {
|
||||
const newOrder = [...wallets]
|
||||
|
||||
const [source] = newOrder.splice(sourceIndex, 1)
|
||||
const newTargetIndex = sourceIndex < targetIndex ? targetIndex - 1 : targetIndex
|
||||
const append = sourceIndex < targetIndex
|
||||
|
||||
newOrder.splice(newTargetIndex + (append ? 1 : 0), 0, source)
|
||||
|
||||
await Promise.all(
|
||||
newOrder.map((w, i) =>
|
||||
w.setPriority(i).catch(console.error)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export default function Wallet ({ ssrData }) {
|
||||
const { wallets } = useWallets()
|
||||
|
||||
const { wallets, setPriorities } = useWallets()
|
||||
const toast = useToast()
|
||||
const isClient = useIsClient()
|
||||
const [sourceIndex, setSourceIndex] = useState(null)
|
||||
const [targetIndex, setTargetIndex] = useState(null)
|
||||
|
||||
const onDragStart = (i) => (e) => {
|
||||
const reorder = useCallback(async (sourceIndex, targetIndex) => {
|
||||
const newOrder = [...wallets.filter(w => w.config?.enabled)]
|
||||
const [source] = newOrder.splice(sourceIndex, 1)
|
||||
const newTargetIndex = sourceIndex < targetIndex ? targetIndex - 1 : targetIndex
|
||||
|
||||
const priorities = newOrder.slice(0, newTargetIndex)
|
||||
.concat(source)
|
||||
.concat(newOrder.slice(newTargetIndex))
|
||||
.map((w, i) => ({ wallet: w, priority: i }))
|
||||
|
||||
await setPriorities(priorities)
|
||||
}, [setPriorities, wallets])
|
||||
|
||||
const onDragStart = useCallback((i) => (e) => {
|
||||
// e.dataTransfer.dropEffect = 'move'
|
||||
// We can only use the DataTransfer API inside the drop event
|
||||
// see https://html.spec.whatwg.org/multipage/dnd.html#security-risks-in-the-drag-and-drop-model
|
||||
// e.dataTransfer.setData('text/plain', name)
|
||||
// That's why we use React state instead
|
||||
setSourceIndex(i)
|
||||
}
|
||||
}, [setSourceIndex])
|
||||
|
||||
const onDragEnter = (i) => (e) => {
|
||||
const onDragEnter = useCallback((i) => (e) => {
|
||||
setTargetIndex(i)
|
||||
}
|
||||
}, [setTargetIndex])
|
||||
|
||||
const onDragEnd = async (e) => {
|
||||
const onReorderError = useCallback((err) => {
|
||||
console.error(err)
|
||||
toast.danger('failed to reorder wallets')
|
||||
}, [toast])
|
||||
|
||||
const onDragEnd = useCallback((e) => {
|
||||
setSourceIndex(null)
|
||||
setTargetIndex(null)
|
||||
|
||||
if (sourceIndex === targetIndex) return
|
||||
|
||||
await reorder(wallets, sourceIndex, targetIndex)
|
||||
}
|
||||
reorder(sourceIndex, targetIndex).catch(onReorderError)
|
||||
}, [sourceIndex, targetIndex, reorder, onReorderError])
|
||||
|
||||
const onTouchStart = (i) => async (e) => {
|
||||
const onTouchStart = useCallback((i) => (e) => {
|
||||
if (sourceIndex !== null) {
|
||||
await reorder(wallets, sourceIndex, i)
|
||||
reorder(sourceIndex, i).catch(onReorderError)
|
||||
setSourceIndex(null)
|
||||
} else {
|
||||
setSourceIndex(i)
|
||||
}
|
||||
}
|
||||
}, [sourceIndex, reorder, onReorderError])
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
|
@ -76,44 +77,33 @@ export default function Wallet ({ ssrData }) {
|
|||
</Link>
|
||||
</div>
|
||||
<div className={styles.walletGrid} onDragEnd={onDragEnd}>
|
||||
{wallets
|
||||
.sort((w1, w2) => {
|
||||
// enabled/configured wallets always come before disabled/unconfigured wallets
|
||||
if ((w1.enabled && !w2.enabled) || (w1.isConfigured && !w2.isConfigured)) {
|
||||
return -1
|
||||
} else if ((w2.enabled && !w1.enabled) || (w2.isConfigured && !w1.isConfigured)) {
|
||||
return 1
|
||||
}
|
||||
{wallets.map((w, i) => {
|
||||
const draggable = isClient && w.config?.enabled
|
||||
|
||||
return walletPrioritySort(w1, w2)
|
||||
})
|
||||
.map((w, i) => {
|
||||
const draggable = isClient && w.enabled
|
||||
|
||||
return (
|
||||
<div
|
||||
key={w.name}
|
||||
className={
|
||||
return (
|
||||
<div
|
||||
key={w.def.name}
|
||||
className={
|
||||
!draggable
|
||||
? ''
|
||||
: (`${sourceIndex === i ? styles.drag : ''} ${draggable && targetIndex === i ? styles.drop : ''}`)
|
||||
}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<WalletCard
|
||||
wallet={w}
|
||||
draggable={draggable}
|
||||
onDragStart={draggable ? onDragStart(i) : undefined}
|
||||
onTouchStart={draggable ? onTouchStart(i) : undefined}
|
||||
onDragEnter={draggable ? onDragEnter(i) : undefined}
|
||||
sourceIndex={sourceIndex}
|
||||
targetIndex={targetIndex}
|
||||
index={i}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<WalletCard
|
||||
wallet={w}
|
||||
draggable={draggable}
|
||||
onDragStart={draggable ? onDragStart(i) : undefined}
|
||||
onTouchStart={draggable ? onTouchStart(i) : undefined}
|
||||
onDragEnter={draggable ? onDragEnter(i) : undefined}
|
||||
sourceIndex={sourceIndex}
|
||||
targetIndex={targetIndex}
|
||||
index={i}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -0,0 +1,64 @@
|
|||
-- AlterEnum
|
||||
-- This migration adds more than one value to an enum.
|
||||
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||
-- in a single migration. This can be worked around by creating
|
||||
-- multiple migrations, each migration adding only one value to
|
||||
-- the enum.
|
||||
|
||||
|
||||
ALTER TYPE "WalletType" ADD VALUE 'LNC';
|
||||
ALTER TYPE "WalletType" ADD VALUE 'WEBLN';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "vaultKeyHash" TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN "walletsUpdatedAt" TIMESTAMP(3);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "VaultEntry" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"key" TEXT NOT NULL,
|
||||
"iv" TEXT NOT NULL,
|
||||
"value" TEXT NOT NULL,
|
||||
"userId" INTEGER NOT NULL,
|
||||
"walletId" INTEGER,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "VaultEntry_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "VaultEntry_walletId_idx" ON "VaultEntry"("walletId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "VaultEntry_userId_key_key" ON "VaultEntry"("userId", "key");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Wallet_priority_idx" ON "Wallet"("priority");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "VaultEntry" ADD CONSTRAINT "VaultEntry_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "VaultEntry" ADD CONSTRAINT "VaultEntry_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "Wallet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
CREATE OR REPLACE FUNCTION wallet_updated_at_trigger() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
UPDATE "users"
|
||||
SET "walletsUpdatedAt" = NOW()
|
||||
WHERE "id" = CASE
|
||||
WHEN TG_OP = 'DELETE'
|
||||
THEN OLD."userId"
|
||||
ELSE NEW."userId"
|
||||
END;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE TRIGGER wallet_updated_at_trigger
|
||||
AFTER INSERT OR UPDATE OR DELETE ON "Wallet"
|
||||
FOR EACH ROW EXECUTE PROCEDURE wallet_updated_at_trigger();
|
||||
|
||||
CREATE OR REPLACE TRIGGER vault_entry_updated_at_trigger
|
||||
AFTER INSERT OR UPDATE OR DELETE ON "VaultEntry"
|
||||
FOR EACH ROW EXECUTE PROCEDURE wallet_updated_at_trigger();
|
|
@ -137,6 +137,9 @@ model User {
|
|||
ItemUserAgg ItemUserAgg[]
|
||||
oneDayReferrals OneDayReferral[] @relation("OneDayReferral_referrer")
|
||||
oneDayReferrees OneDayReferral[] @relation("OneDayReferral_referrees")
|
||||
vaultKeyHash String @default("")
|
||||
walletsUpdatedAt DateTime?
|
||||
vaultEntries VaultEntry[] @relation("VaultEntries")
|
||||
|
||||
@@index([photoId])
|
||||
@@index([createdAt], map: "users.created_at_index")
|
||||
|
@ -180,6 +183,8 @@ enum WalletType {
|
|||
NWC
|
||||
PHOENIXD
|
||||
BLINK
|
||||
LNC
|
||||
WEBLN
|
||||
}
|
||||
|
||||
model Wallet {
|
||||
|
@ -207,10 +212,29 @@ model Wallet {
|
|||
walletNWC WalletNWC?
|
||||
walletPhoenixd WalletPhoenixd?
|
||||
walletBlink WalletBlink?
|
||||
withdrawals Withdrawl[]
|
||||
InvoiceForward InvoiceForward[]
|
||||
|
||||
vaultEntries VaultEntry[] @relation("VaultEntries")
|
||||
withdrawals Withdrawl[]
|
||||
InvoiceForward InvoiceForward[]
|
||||
|
||||
@@index([userId])
|
||||
@@index([priority])
|
||||
}
|
||||
|
||||
model VaultEntry {
|
||||
id Int @id @default(autoincrement())
|
||||
key String @db.Text
|
||||
iv String @db.Text
|
||||
value String @db.Text
|
||||
userId Int
|
||||
walletId Int?
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade, name: "VaultEntries")
|
||||
wallet Wallet? @relation(fields: [walletId], references: [id], onDelete: Cascade, name: "VaultEntries")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
|
||||
@@unique([userId, key])
|
||||
@@index([walletId])
|
||||
}
|
||||
|
||||
model WalletLog {
|
||||
|
@ -276,12 +300,12 @@ model WalletNWC {
|
|||
}
|
||||
|
||||
model WalletBlink {
|
||||
id Int @id @default(autoincrement())
|
||||
walletId Int @unique
|
||||
wallet Wallet @relation(fields: [walletId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
apiKeyRecv String
|
||||
id Int @id @default(autoincrement())
|
||||
walletId Int @unique
|
||||
wallet Wallet @relation(fields: [walletId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
apiKeyRecv String
|
||||
currencyRecv String?
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M7 4V2H17V4H20.0066C20.5552 4 21 4.44495 21 4.9934V21.0066C21 21.5552 20.5551 22 20.0066 22H3.9934C3.44476 22 3 21.5551 3 21.0066V4.9934C3 4.44476 3.44495 4 3.9934 4H7ZM7 6H5V20H19V6H17V8H7V6ZM9 4V6H15V4H9Z"></path></svg>
|
After Width: | Height: | Size: 310 B |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M16 17V16H13V13H16V15H18V17H17V19H15V21H13V18H15V17H16ZM21 21H17V19H19V17H21V21ZM3 3H11V11H3V3ZM5 5V9H9V5H5ZM13 3H21V11H13V3ZM15 5V9H19V5H15ZM3 13H11V21H3V13ZM5 15V19H9V15H5ZM18 13H21V15H18V13ZM6 6H8V8H6V6ZM6 16H8V18H6V16ZM16 6H18V8H16V6Z"></path></svg>
|
After Width: | Height: | Size: 342 B |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M21 16V21H3V16H5V19H19V16H21ZM3 11H21V13H3V11ZM21 8H19V5H5V8H3V3H21V8Z"></path></svg>
|
After Width: | Height: | Size: 174 B |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M5.46257 4.43262C7.21556 2.91688 9.5007 2 12 2C17.5228 2 22 6.47715 22 12C22 14.1361 21.3302 16.1158 20.1892 17.7406L17 12H20C20 7.58172 16.4183 4 12 4C9.84982 4 7.89777 4.84827 6.46023 6.22842L5.46257 4.43262ZM18.5374 19.5674C16.7844 21.0831 14.4993 22 12 22C6.47715 22 2 17.5228 2 12C2 9.86386 2.66979 7.88416 3.8108 6.25944L7 12H4C4 16.4183 7.58172 20 12 20C14.1502 20 16.1022 19.1517 17.5398 17.7716L18.5374 19.5674Z"></path></svg>
|
After Width: | Height: | Size: 524 B |
|
@ -55,7 +55,7 @@ This acts as an ID for this wallet on the client. It therefore must be unique ac
|
|||
|
||||
- `shortName?: string`
|
||||
|
||||
Since `name` will also be used in [wallet logs](https://stacker.news/wallet/logs), you can specify a shorter name here which will be used in logs instead.
|
||||
This is an optional value. Set this to true if your wallet needs to be configured per device and should thus not be synced across devices.
|
||||
|
||||
- `fields: WalletField[]`
|
||||
|
||||
|
@ -65,17 +65,11 @@ Wallet fields define what this wallet requires for configuration and thus are us
|
|||
|
||||
Wallet cards are the components you can see at [/settings/wallets](https://stacker.news/settings/wallets). This property customizes this card for this wallet.
|
||||
|
||||
- `fieldValidation: (config) => { [key: string]: string } | Yup.ObjectSchema`
|
||||
- `validate: (config) => void`
|
||||
|
||||
This property defines how Formik should perform form-level validation. As mentioned in the [documentation](https://formik.org/docs/guides/validation#form-level-validation), Formik supports two ways to perform such validation.
|
||||
This is an optional function that's passed the final config after it has been validated. Validation is otherwise done on each individual field in `fields. This function can be used to implement additional validation logic. If the validation fails, the function should throw an error with a descriptive message for the user.
|
||||
|
||||
If a function is used for `fieldValidation`, the built-in form-level validation is used via the [`validate`](https://formik.org/docs/guides/validation#validate) property of the Formik form component.
|
||||
|
||||
If a [Yup object schema](https://github.com/jquense/yup?tab=readme-ov-file#object) is set, [`validationSchema`](https://formik.org/docs/guides/validation#validationschema) will be used instead.
|
||||
|
||||
This validation is triggered on every submit and on every change after the first submit attempt.
|
||||
|
||||
Refer to the [Formik documentation](https://formik.org/docs/guides/validation) for more details.
|
||||
This validation is triggered on save.
|
||||
|
||||
- `walletType?: string`
|
||||
|
||||
|
@ -104,6 +98,12 @@ The label of the configuration key. Will be shown to the user in the form.
|
|||
|
||||
The input type that should be used for this value. For example, if the type is `password`, the input value will be hidden by default using a component for passwords.
|
||||
|
||||
- `validate: Yup.Schema | ((value) => void) | RegExp`
|
||||
|
||||
This property defines how the value for this field should be validated. If a [Yup schema](https://github.com/jquense/yup?tab=readme-ov-file#object) is set, it will be used. Otherwise, the value will be validated by the function or the RegExp. When using a function, it is expected to throw an error with a descriptive message if the value is invalid.
|
||||
|
||||
The validate field is required.
|
||||
|
||||
- `optional?: boolean | string = false`
|
||||
|
||||
This property can be used to mark a wallet field as optional. If it is not set, we will assume this field is required else 'optional' will be shown to the user next to the label. You can use Markdown to customize this text.
|
||||
|
@ -132,6 +132,16 @@ If a button to clear the input after it has been set should be shown, set this p
|
|||
|
||||
This property controls the HTML `autocomplete` attribute. See [the documentation](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete) for possible values. Not setting it usually means that the user agent can use autocompletion. This property has no effect for passwords. Autocompletion is always turned off for passwords to prevent passwords getting saved for security reasons.
|
||||
|
||||
- `clientOnly?: boolean = false`
|
||||
|
||||
If this property is set to `true`, this field is only available on the client. If the stacker has device sync enabled, this field will be encrypted before being synced across devices. Otherwise, the field will be stored only on the current device.
|
||||
|
||||
- `serverOnly?: boolean = false`
|
||||
|
||||
If this property is set to `true`, this field is only meant to be used on the server and is safe to sync across devices in plain text.
|
||||
|
||||
If neither `clientOnly` nor `serverOnly` is set, the field is assumed to be used on both the client and the server and safe to sync across devices in plain text.
|
||||
|
||||
#### WalletCard
|
||||
|
||||
- `title: string`
|
||||
|
|
|
@ -1,21 +1,22 @@
|
|||
import { blinkSchema } from '@/lib/validate'
|
||||
import { string } from '@/lib/yup'
|
||||
import { galoyBlinkDashboardUrl } from 'wallets/blink/common'
|
||||
|
||||
export const name = 'blink'
|
||||
export const walletType = 'BLINK'
|
||||
export const walletField = 'walletBlink'
|
||||
export const fieldValidation = blinkSchema
|
||||
|
||||
export const fields = [
|
||||
{
|
||||
name: 'apiKey',
|
||||
label: 'api key',
|
||||
type: 'password',
|
||||
help: `you can get an API key from [Blink Dashboard](${galoyBlinkDashboardUrl}).\nPlease make sure to select ONLY the 'Read' and 'Write' scopes when generating this API key.`,
|
||||
placeholder: 'blink_...',
|
||||
optional: 'for sending',
|
||||
clientOnly: true,
|
||||
editable: false
|
||||
validate: string()
|
||||
.matches(/^blink_[A-Za-z0-9]+$/, { message: 'must match pattern blink_A-Za-z0-9' }),
|
||||
help: `you can get an API key from [Blink Dashboard](${galoyBlinkDashboardUrl}).\nPlease make sure to select ONLY the 'Read' and 'Write' scopes when generating this API key.`,
|
||||
optional: 'for sending',
|
||||
requiredWithout: ['apiKeyRecv']
|
||||
},
|
||||
{
|
||||
name: 'currency',
|
||||
|
@ -25,9 +26,11 @@ export const fields = [
|
|||
placeholder: 'BTC',
|
||||
clear: true,
|
||||
autoComplete: 'off',
|
||||
optional: 'for sending',
|
||||
clientOnly: true,
|
||||
editable: false
|
||||
validate: string()
|
||||
.transform(value => value ? value.toUpperCase() : 'BTC')
|
||||
.oneOf(['USD', 'BTC'], 'must be BTC or USD'),
|
||||
optional: 'for sending'
|
||||
},
|
||||
{
|
||||
name: 'apiKeyRecv',
|
||||
|
@ -37,7 +40,9 @@ export const fields = [
|
|||
placeholder: 'blink_...',
|
||||
optional: 'for receiving',
|
||||
serverOnly: true,
|
||||
editable: false
|
||||
requiredWithout: ['apiKey'],
|
||||
validate: string()
|
||||
.matches(/^blink_[A-Za-z0-9]+$/, { message: 'must match pattern blink_A-Za-z0-9' })
|
||||
},
|
||||
{
|
||||
name: 'currencyRecv',
|
||||
|
@ -49,13 +54,14 @@ export const fields = [
|
|||
autoComplete: 'off',
|
||||
optional: 'for receiving',
|
||||
serverOnly: true,
|
||||
editable: false
|
||||
|
||||
validate: string()
|
||||
.transform(value => value ? value.toUpperCase() : 'BTC')
|
||||
.oneOf(['BTC'], 'must be BTC')
|
||||
}
|
||||
]
|
||||
|
||||
export const card = {
|
||||
title: 'Blink',
|
||||
subtitle: 'use [Blink](https://blink.sv/) for payments',
|
||||
badges: ['send & receive']
|
||||
badges: ['send', 'receive']
|
||||
}
|
||||
|
|
|
@ -1,6 +1,10 @@
|
|||
import { CLNAutowithdrawSchema } from '@/lib/validate'
|
||||
import { decodeRune } from '@/lib/cln'
|
||||
import { B64_URL_REGEX } from '@/lib/format'
|
||||
import { string } from '@/lib/yup'
|
||||
|
||||
export const name = 'cln'
|
||||
export const walletType = 'CLN'
|
||||
export const walletField = 'walletCLN'
|
||||
|
||||
export const fields = [
|
||||
{
|
||||
|
@ -9,7 +13,9 @@ export const fields = [
|
|||
type: 'text',
|
||||
placeholder: '55.5.555.55:3010',
|
||||
hint: 'tor or clearnet',
|
||||
clear: true
|
||||
clear: true,
|
||||
serverOnly: true,
|
||||
validate: string().socket()
|
||||
},
|
||||
{
|
||||
name: 'rune',
|
||||
|
@ -20,7 +26,26 @@ export const fields = [
|
|||
type: 'text',
|
||||
placeholder: 'S34KtUW-6gqS_hD_9cc_PNhfF-NinZyBOCgr1aIrark9NCZtZXRob2Q9aW52b2ljZQ==',
|
||||
hint: 'must be restricted to method=invoice',
|
||||
clear: true
|
||||
clear: 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',
|
||||
|
@ -29,7 +54,9 @@ 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,
|
||||
serverOnly: true,
|
||||
validate: string().hexOrBase64()
|
||||
}
|
||||
]
|
||||
|
||||
|
@ -38,9 +65,3 @@ export const card = {
|
|||
subtitle: 'autowithdraw to your Core Lightning node via [CLNRest](https://docs.corelightning.org/docs/rest)',
|
||||
badges: ['receive']
|
||||
}
|
||||
|
||||
export const fieldValidation = CLNAutowithdrawSchema
|
||||
|
||||
export const walletType = 'CLN'
|
||||
|
||||
export const walletField = 'walletCLN'
|
||||
|
|
|
@ -0,0 +1,163 @@
|
|||
import walletDefs from 'wallets/client'
|
||||
|
||||
export const Status = {
|
||||
Enabled: 'Enabled',
|
||||
Disabled: 'Disabled'
|
||||
}
|
||||
|
||||
export function getWalletByName (name) {
|
||||
return walletDefs.find(def => def.name === name)
|
||||
}
|
||||
|
||||
export function getWalletByType (type) {
|
||||
return walletDefs.find(def => def.walletType === type)
|
||||
}
|
||||
|
||||
export function getStorageKey (name, userId) {
|
||||
let storageKey = `wallet:${name}`
|
||||
|
||||
// WebLN has no credentials we need to scope to users
|
||||
// so we can use the same storage key for all users
|
||||
if (userId && name !== 'webln') {
|
||||
storageKey = `${storageKey}:${userId}`
|
||||
}
|
||||
|
||||
return storageKey
|
||||
}
|
||||
|
||||
export function walletPrioritySort (w1, w2) {
|
||||
// enabled/configured wallets always come before disabled/unconfigured wallets
|
||||
if ((w1.config?.enabled && !w2.config?.enabled) || (isConfigured(w1) && !isConfigured(w2))) {
|
||||
return -1
|
||||
} else if ((w2.config?.enabled && !w1.config?.enabled) || (isConfigured(w2) && !isConfigured(w1))) {
|
||||
return 1
|
||||
}
|
||||
|
||||
const delta = w1.config?.priority - w2.config?.priority
|
||||
// delta is NaN if either priority is undefined
|
||||
if (!Number.isNaN(delta) && delta !== 0) return delta
|
||||
|
||||
// if one wallet has a priority but the other one doesn't, the one with the priority comes first
|
||||
if (w1.config?.priority !== undefined && w2.config?.priority === undefined) return -1
|
||||
if (w1.config?.priority === undefined && w2.config?.priority !== undefined) return 1
|
||||
|
||||
// both wallets have no priority set, falling back to other methods
|
||||
|
||||
// if both wallets have an id, use that as tie breaker
|
||||
// since that's the order in which autowithdrawals are attempted
|
||||
if (w1.config?.id && w2.config?.id) return Number(w1.config.id) - Number(w2.config.id)
|
||||
|
||||
// else we will use the card title as tie breaker
|
||||
return w1.def.card.title < w2.def.card.title ? -1 : 1
|
||||
}
|
||||
|
||||
export function isServerField (f) {
|
||||
return f.serverOnly || !f.clientOnly
|
||||
}
|
||||
|
||||
export function isClientField (f) {
|
||||
return f.clientOnly || !f.serverOnly
|
||||
}
|
||||
|
||||
function checkFields ({ fields, config }) {
|
||||
// a wallet is configured if all of its required fields are set
|
||||
let val = fields.every(f => {
|
||||
if ((f.optional || f.generated) && !f.requiredWithout) return true
|
||||
return !!config?.[f.name]
|
||||
})
|
||||
|
||||
// however, a wallet is not configured if all fields are optional and none are set
|
||||
// since that usually means that one of them is required
|
||||
if (val && fields.length > 0) {
|
||||
val = !(fields.every(f => f.optional || f.generated) && fields.every(f => !config?.[f.name]))
|
||||
}
|
||||
|
||||
return val
|
||||
}
|
||||
|
||||
export function isConfigured ({ def, config }) {
|
||||
return isSendConfigured({ def, config }) || isReceiveConfigured({ def, config })
|
||||
}
|
||||
|
||||
function isSendConfigured ({ def, config }) {
|
||||
const fields = def.fields.filter(isClientField)
|
||||
return (fields.length > 0 || def.isAvailable?.()) && checkFields({ fields, config })
|
||||
}
|
||||
|
||||
function isReceiveConfigured ({ def, config }) {
|
||||
const fields = def.fields.filter(isServerField)
|
||||
return fields.length > 0 && checkFields({ fields, config })
|
||||
}
|
||||
|
||||
export function canSend ({ def, config }) {
|
||||
return !!def.sendPayment && isSendConfigured({ def, config })
|
||||
}
|
||||
|
||||
export function canReceive ({ def, config }) {
|
||||
return def.fields.some(f => f.serverOnly) && isReceiveConfigured({ def, config })
|
||||
}
|
||||
|
||||
export function siftConfig (fields, config) {
|
||||
const sifted = {
|
||||
clientOnly: {},
|
||||
serverOnly: {},
|
||||
shared: {},
|
||||
serverWithShared: {},
|
||||
clientWithShared: {},
|
||||
settings: null
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
if (['id'].includes(key)) {
|
||||
sifted.serverOnly[key] = value
|
||||
continue
|
||||
}
|
||||
|
||||
if (['autoWithdrawMaxFeePercent', 'autoWithdrawThreshold', 'autoWithdrawMaxFeeTotal'].includes(key)) {
|
||||
sifted.serverOnly[key] = Number(value)
|
||||
sifted.settings = { ...sifted.settings, [key]: Number(value) }
|
||||
continue
|
||||
}
|
||||
|
||||
const field = fields.find(({ name }) => name === key)
|
||||
|
||||
if (field) {
|
||||
if (field.serverOnly) {
|
||||
sifted.serverOnly[key] = value
|
||||
} else if (field.clientOnly) {
|
||||
sifted.clientOnly[key] = value
|
||||
} else {
|
||||
sifted.shared[key] = value
|
||||
}
|
||||
} else if (['enabled', 'priority'].includes(key)) {
|
||||
sifted.shared[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
sifted.serverWithShared = { ...sifted.shared, ...sifted.serverOnly }
|
||||
sifted.clientWithShared = { ...sifted.shared, ...sifted.clientOnly }
|
||||
|
||||
return sifted
|
||||
}
|
||||
|
||||
export async function upsertWalletVariables ({ def, config }, encrypt, append = {}) {
|
||||
const { serverWithShared, settings, clientOnly } = siftConfig(def.fields, config)
|
||||
// if we are disconnected from the vault, we leave vaultEntries undefined so we don't
|
||||
// delete entries from connected devices
|
||||
let vaultEntries
|
||||
if (clientOnly && encrypt) {
|
||||
vaultEntries = []
|
||||
for (const [key, value] of Object.entries(clientOnly)) {
|
||||
if (value) {
|
||||
vaultEntries.push({ key, ...await encrypt(value) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { ...serverWithShared, settings, vaultEntries, ...append }
|
||||
}
|
||||
|
||||
export async function saveWalletLocally (name, config, userId) {
|
||||
const storageKey = getStorageKey(name, userId)
|
||||
window.localStorage.setItem(storageKey, JSON.stringify(config))
|
||||
}
|
|
@ -0,0 +1,118 @@
|
|||
import { useMe } from '@/components/me'
|
||||
import useVault from '@/components/vault/use-vault'
|
||||
import { useCallback } from 'react'
|
||||
import { canReceive, canSend, getStorageKey, saveWalletLocally, siftConfig, upsertWalletVariables } from './common'
|
||||
import { useMutation } from '@apollo/client'
|
||||
import { generateMutation } from './graphql'
|
||||
import { REMOVE_WALLET } from '@/fragments/wallet'
|
||||
import { useWalletLogger } from '@/components/wallet-logger'
|
||||
import { useWallets } from '.'
|
||||
import validateWallet from './validate'
|
||||
|
||||
export function useWalletConfigurator (wallet) {
|
||||
const { me } = useMe()
|
||||
const { reloadLocalWallets } = useWallets()
|
||||
const { encrypt, isActive } = useVault()
|
||||
const { logger } = useWalletLogger(wallet?.def)
|
||||
const [upsertWallet] = useMutation(generateMutation(wallet?.def))
|
||||
const [removeWallet] = useMutation(REMOVE_WALLET)
|
||||
|
||||
const _saveToServer = useCallback(async (serverConfig, clientConfig, validateLightning) => {
|
||||
const variables = await upsertWalletVariables(
|
||||
{ def: wallet.def, config: { ...serverConfig, ...clientConfig } },
|
||||
isActive && encrypt,
|
||||
{ validateLightning })
|
||||
await upsertWallet({ variables })
|
||||
}, [encrypt, isActive, wallet.def])
|
||||
|
||||
const _saveToLocal = useCallback(async (newConfig) => {
|
||||
saveWalletLocally(wallet.def.name, newConfig, me?.id)
|
||||
reloadLocalWallets()
|
||||
}, [me?.id, wallet.def.name, reloadLocalWallets])
|
||||
|
||||
const _validate = useCallback(async (config, validateLightning = true) => {
|
||||
const { serverWithShared, clientWithShared } = siftConfig(wallet.def.fields, config)
|
||||
|
||||
let clientConfig = clientWithShared
|
||||
let serverConfig = serverWithShared
|
||||
|
||||
if (canSend({ def: wallet.def, config: clientConfig })) {
|
||||
let transformedConfig = await validateWallet(wallet.def, clientWithShared, { skipGenerated: true })
|
||||
if (transformedConfig) {
|
||||
clientConfig = Object.assign(clientConfig, transformedConfig)
|
||||
}
|
||||
if (wallet.def.testSendPayment && validateLightning) {
|
||||
transformedConfig = await wallet.def.testSendPayment(clientConfig, { me, logger })
|
||||
if (transformedConfig) {
|
||||
clientConfig = Object.assign(clientConfig, transformedConfig)
|
||||
}
|
||||
// validate again to ensure generated fields are valid
|
||||
await validateWallet(wallet.def, clientConfig)
|
||||
}
|
||||
} else if (canReceive({ def: wallet.def, config: serverConfig })) {
|
||||
const transformedConfig = await validateWallet(wallet.def, serverConfig)
|
||||
if (transformedConfig) {
|
||||
serverConfig = Object.assign(serverConfig, transformedConfig)
|
||||
}
|
||||
} else {
|
||||
throw new Error('configuration must be able to send or receive')
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// if vault is active, encrypt and send to server regardless of wallet type
|
||||
if (isActive) {
|
||||
await _saveToServer(serverConfig, clientConfig, validateLightning)
|
||||
await _detachFromLocal()
|
||||
} 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 (wallet.config.id) {
|
||||
// we previously had a server config
|
||||
if (wallet.vaultEntries.length > 0) {
|
||||
// we previously had a server config with vault entries, save it
|
||||
await _saveToServer(serverConfig, clientConfig, validateLightning)
|
||||
} else {
|
||||
// we previously had a server config without vault entries, remove it
|
||||
await _detachFromServer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [isActive, wallet.def, _saveToServer, _saveToLocal, _validate,
|
||||
_detachFromLocal, _detachFromServer])
|
||||
|
||||
const detach = useCallback(async () => {
|
||||
if (isActive) {
|
||||
// if vault is active, detach all wallets from server
|
||||
await _detachFromServer()
|
||||
} else {
|
||||
if (wallet.config.id) {
|
||||
await _detachFromServer()
|
||||
}
|
||||
|
||||
// if vault is not active and has a client config, delete from local storage
|
||||
await _detachFromLocal()
|
||||
}
|
||||
}, [isActive, _detachFromServer, _detachFromLocal])
|
||||
|
||||
return { save, detach }
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
export class InvoiceCanceledError extends Error {
|
||||
constructor (hash, actionError) {
|
||||
super(actionError ?? `invoice canceled: ${hash}`)
|
||||
this.name = 'InvoiceCanceledError'
|
||||
this.hash = hash
|
||||
this.actionError = actionError
|
||||
}
|
||||
}
|
||||
|
||||
export class NoAttachedWalletError extends Error {
|
||||
constructor () {
|
||||
super('no attached wallet found')
|
||||
this.name = 'NoAttachedWalletError'
|
||||
}
|
||||
}
|
||||
|
||||
export class InvoiceExpiredError extends Error {
|
||||
constructor (hash) {
|
||||
super(`invoice expired: ${hash}`)
|
||||
this.name = 'InvoiceExpiredError'
|
||||
}
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
import gql from 'graphql-tag'
|
||||
import { isServerField } from './common'
|
||||
// for some reason, this is needed to make the import work from worker
|
||||
import * as walletFragments from '@/fragments/wallet'
|
||||
const { WALLET_FIELDS } = walletFragments
|
||||
|
||||
export function fieldToGqlArg (field) {
|
||||
let arg = `${field.name}: String`
|
||||
if (!field.optional) {
|
||||
arg += '!'
|
||||
}
|
||||
return arg
|
||||
}
|
||||
|
||||
// same as fieldToGqlArg, but makes the field always optional
|
||||
export function fieldToGqlArgOptional (field) {
|
||||
return `${field.name}: String`
|
||||
}
|
||||
|
||||
export function generateResolverName (walletField) {
|
||||
const capitalized = walletField[0].toUpperCase() + walletField.slice(1)
|
||||
return `upsert${capitalized}`
|
||||
}
|
||||
|
||||
export function generateTypeDefName (walletType) {
|
||||
const PascalCase = walletType.split('_').map(s => s[0].toUpperCase() + s.slice(1).toLowerCase()).join('')
|
||||
return `Wallet${PascalCase}`
|
||||
}
|
||||
|
||||
export function generateMutation (wallet) {
|
||||
const resolverName = generateResolverName(wallet.walletField)
|
||||
|
||||
let headerArgs = '$id: ID, '
|
||||
headerArgs += wallet.fields
|
||||
.filter(isServerField)
|
||||
.map(f => `$${f.name}: String`)
|
||||
.join(', ')
|
||||
headerArgs += ', $enabled: Boolean, $priority: Int, $vaultEntries: [VaultEntryInput!], $settings: AutowithdrawSettings, $validateLightning: Boolean'
|
||||
|
||||
let inputArgs = 'id: $id, '
|
||||
inputArgs += wallet.fields
|
||||
.filter(isServerField)
|
||||
.map(f => `${f.name}: $${f.name}`).join(', ')
|
||||
inputArgs += ', enabled: $enabled, priority: $priority, vaultEntries: $vaultEntries, settings: $settings, validateLightning: $validateLightning'
|
||||
|
||||
return gql`
|
||||
${WALLET_FIELDS}
|
||||
mutation ${resolverName}(${headerArgs}) {
|
||||
${resolverName}(${inputArgs}) {
|
||||
...WalletFields
|
||||
}
|
||||
}`
|
||||
}
|
637
wallets/index.js
637
wallets/index.js
|
@ -1,452 +1,239 @@
|
|||
import { useCallback } from 'react'
|
||||
import { useMe } from '@/components/me'
|
||||
import useClientConfig from '@/components/use-local-state'
|
||||
import { useWalletLogger } from '@/components/wallet-logger'
|
||||
import { SET_WALLET_PRIORITY, WALLETS } from '@/fragments/wallet'
|
||||
import { SSR } from '@/lib/constants'
|
||||
import { useApolloClient, useMutation, useQuery } from '@apollo/client'
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import { getStorageKey, getWalletByType, Status, walletPrioritySort, canSend, isConfigured, upsertWalletVariables, siftConfig, saveWalletLocally } from './common'
|
||||
import useVault from '@/components/vault/use-vault'
|
||||
import { useWalletLogger } from '@/components/wallet-logger'
|
||||
import { bolt11Tags } from '@/lib/bolt11'
|
||||
|
||||
import walletDefs from 'wallets/client'
|
||||
import { gql, useApolloClient, useMutation, useQuery } from '@apollo/client'
|
||||
import { REMOVE_WALLET, WALLET_BY_TYPE } from '@/fragments/wallet'
|
||||
import { autowithdrawInitial } from '@/components/autowithdraw-shared'
|
||||
import { useShowModal } from '@/components/modal'
|
||||
import { useToast } from '../components/toast'
|
||||
import { generateResolverName } from '@/lib/wallet'
|
||||
import { walletValidate } from '@/lib/validate'
|
||||
import { generateMutation } from './graphql'
|
||||
|
||||
export const Status = {
|
||||
Initialized: 'Initialized',
|
||||
Enabled: 'Enabled',
|
||||
Locked: 'Locked',
|
||||
Error: 'Error'
|
||||
const WalletsContext = createContext({
|
||||
wallets: []
|
||||
})
|
||||
|
||||
function useLocalWallets () {
|
||||
const { me } = useMe()
|
||||
const [wallets, setWallets] = useState([])
|
||||
|
||||
const loadWallets = useCallback(() => {
|
||||
// form wallets from local storage into a list of { config, def }
|
||||
const wallets = walletDefs.map(w => {
|
||||
try {
|
||||
const storageKey = getStorageKey(w.name, me?.id)
|
||||
const config = window.localStorage.getItem(storageKey)
|
||||
return { def: w, config: JSON.parse(config) }
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}).filter(Boolean)
|
||||
setWallets(wallets)
|
||||
}, [me?.id, setWallets])
|
||||
|
||||
const removeWallets = useCallback(() => {
|
||||
for (const wallet of wallets) {
|
||||
const storageKey = getStorageKey(wallet.def.name, me?.id)
|
||||
window.localStorage.removeItem(storageKey)
|
||||
}
|
||||
setWallets([])
|
||||
}, [wallets, setWallets, me?.id])
|
||||
|
||||
useEffect(() => {
|
||||
loadWallets()
|
||||
}, [loadWallets])
|
||||
|
||||
return { wallets, reloadLocalWallets: loadWallets, removeLocalWallets: removeWallets }
|
||||
}
|
||||
|
||||
const walletDefsOnly = walletDefs.map(w => ({ def: w, config: {} }))
|
||||
|
||||
export function WalletsProvider ({ children }) {
|
||||
const { isActive, decrypt } = useVault()
|
||||
const { me } = useMe()
|
||||
const { wallets: localWallets, reloadLocalWallets, removeLocalWallets } = useLocalWallets()
|
||||
const [setWalletPriority] = useMutation(SET_WALLET_PRIORITY)
|
||||
const [serverWallets, setServerWallets] = useState([])
|
||||
const client = useApolloClient()
|
||||
|
||||
const { data, refetch } = useQuery(WALLETS,
|
||||
SSR ? {} : { nextFetchPolicy: 'cache-and-network' })
|
||||
|
||||
// refetch wallets when the vault key hash changes or wallets are updated
|
||||
useEffect(() => {
|
||||
if (me?.privates?.walletsUpdatedAt) {
|
||||
refetch()
|
||||
}
|
||||
}, [me?.privates?.walletsUpdatedAt, me?.privates?.vaultKeyHash, refetch])
|
||||
|
||||
useEffect(() => {
|
||||
const loadWallets = async () => {
|
||||
if (!data?.wallets) return
|
||||
// form wallets into a list of { config, def }
|
||||
const wallets = []
|
||||
for (const w of data.wallets) {
|
||||
const def = getWalletByType(w.type)
|
||||
const { vaultEntries, ...config } = w
|
||||
if (isActive) {
|
||||
for (const { key, iv, value } of vaultEntries) {
|
||||
try {
|
||||
config[key] = await decrypt({ iv, value })
|
||||
} catch (e) {
|
||||
console.error('error decrypting vault entry', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// the specific wallet config on the server is stored in wallet.wallet
|
||||
// on the client, it's stored unnested
|
||||
wallets.push({ config: { ...config, ...w.wallet }, def, vaultEntries })
|
||||
}
|
||||
|
||||
setServerWallets(wallets)
|
||||
}
|
||||
loadWallets()
|
||||
}, [data?.wallets, decrypt, isActive])
|
||||
|
||||
// merge wallets on name like: { ...unconfigured, ...localConfig, ...serverConfig }
|
||||
const wallets = useMemo(() => {
|
||||
const merged = {}
|
||||
for (const wallet of [...walletDefsOnly, ...localWallets, ...serverWallets]) {
|
||||
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]
|
||||
])
|
||||
)
|
||||
},
|
||||
vaultEntries: wallet.vaultEntries
|
||||
}
|
||||
}
|
||||
|
||||
// sort by priority, then add status field
|
||||
return Object.values(merged)
|
||||
.sort(walletPrioritySort)
|
||||
.map(w => ({ ...w, status: w.config?.enabled ? Status.Enabled : Status.Disabled }))
|
||||
}, [serverWallets, localWallets])
|
||||
|
||||
const settings = useMemo(() => {
|
||||
return {
|
||||
autoWithdrawMaxFeePercent: me?.privates?.autoWithdrawMaxFeePercent,
|
||||
autoWithdrawThreshold: me?.privates?.autoWithdrawThreshold,
|
||||
autoWithdrawMaxFeeTotal: me?.privates?.autoWithdrawMaxFeeTotal
|
||||
}
|
||||
}, [me?.privates?.autoWithdrawMaxFeePercent, me?.privates?.autoWithdrawThreshold, me?.privates?.autoWithdrawMaxFeeTotal])
|
||||
|
||||
// whenever the vault key is set, and we have local wallets,
|
||||
// we'll send any merged local wallets to the server, and delete them from local storage
|
||||
const syncLocalWallets = useCallback(async encrypt => {
|
||||
const walletsToSync = wallets.filter(w =>
|
||||
// only sync wallets that have a local config
|
||||
localWallets.some(localWallet => localWallet.def.name === w.def.name && !!localWallet.config)
|
||||
)
|
||||
if (encrypt && walletsToSync.length > 0) {
|
||||
for (const wallet of walletsToSync) {
|
||||
const mutation = generateMutation(wallet.def)
|
||||
const append = {}
|
||||
// if the wallet has server-only fields set, add the settings to the mutation variables
|
||||
if (wallet.def.fields.some(f => f.serverOnly && wallet.config[f.name])) {
|
||||
append.settings = settings
|
||||
}
|
||||
const variables = await upsertWalletVariables(wallet, encrypt, append)
|
||||
await client.mutate({ mutation, variables })
|
||||
}
|
||||
removeLocalWallets()
|
||||
}
|
||||
}, [wallets, localWallets, removeLocalWallets, settings])
|
||||
|
||||
const unsyncLocalWallets = useCallback(() => {
|
||||
for (const wallet of wallets) {
|
||||
const { clientWithShared } = siftConfig(wallet.def.fields, wallet.config)
|
||||
if (canSend({ def: wallet.def, config: clientWithShared })) {
|
||||
saveWalletLocally(wallet.def.name, clientWithShared, me?.id)
|
||||
}
|
||||
}
|
||||
reloadLocalWallets()
|
||||
}, [wallets, me?.id, reloadLocalWallets])
|
||||
|
||||
const setPriorities = useCallback(async (priorities) => {
|
||||
for (const { wallet, priority } of priorities) {
|
||||
if (!isConfigured(wallet)) {
|
||||
throw new Error(`cannot set priority for unconfigured wallet: ${wallet.def.name}`)
|
||||
}
|
||||
|
||||
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)
|
||||
const config = window.localStorage.getItem(storageKey)
|
||||
const newConfig = { ...JSON.parse(config), priority }
|
||||
window.localStorage.setItem(storageKey, JSON.stringify(newConfig))
|
||||
}
|
||||
}
|
||||
// reload local wallets if any priorities were set
|
||||
if (priorities.length > 0) {
|
||||
reloadLocalWallets()
|
||||
}
|
||||
}, [setWalletPriority, me?.id, reloadLocalWallets])
|
||||
|
||||
// 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,
|
||||
onVaultKeySet: syncLocalWallets,
|
||||
beforeDisconnectVault: unsyncLocalWallets,
|
||||
removeLocalWallets
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</WalletsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useWallets () {
|
||||
return useContext(WalletsContext)
|
||||
}
|
||||
|
||||
export function useWallet (name) {
|
||||
const { me } = useMe()
|
||||
const showModal = useShowModal()
|
||||
const toaster = useToast()
|
||||
const [disableFreebies] = useMutation(gql`mutation { disableFreebies }`)
|
||||
const { wallets } = useWallets()
|
||||
|
||||
const wallet = name ? getWalletByName(name) : getEnabledWallet(me)
|
||||
const { logger, deleteLogs } = useWalletLogger(wallet)
|
||||
const wallet = useMemo(() => {
|
||||
if (name) {
|
||||
return wallets.find(w => w.def.name === name)
|
||||
}
|
||||
|
||||
const [config, saveConfig, clearConfig] = useConfig(wallet)
|
||||
const hasConfig = wallet?.fields.length > 0
|
||||
const _isConfigured = isConfigured({ ...wallet, config })
|
||||
// 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]
|
||||
}, [wallets, name])
|
||||
|
||||
const enablePayments = useCallback(() => {
|
||||
enableWallet(name, me)
|
||||
logger.ok('payments enabled')
|
||||
disableFreebies().catch(console.error)
|
||||
}, [name, me, logger])
|
||||
|
||||
const disablePayments = useCallback(() => {
|
||||
disableWallet(name, me)
|
||||
logger.info('payments disabled')
|
||||
}, [name, me, logger])
|
||||
|
||||
const status = config?.enabled ? Status.Enabled : Status.Initialized
|
||||
const enabled = status === Status.Enabled
|
||||
const priority = config?.priority
|
||||
const { logger } = useWalletLogger(wallet?.def)
|
||||
|
||||
const sendPayment = useCallback(async (bolt11) => {
|
||||
const hash = bolt11Tags(bolt11).payment_hash
|
||||
logger.info('sending payment:', `payment_hash=${hash}`)
|
||||
try {
|
||||
const preimage = await wallet.sendPayment(bolt11, config, { me, logger, status, showModal })
|
||||
const preimage = await wallet.def.sendPayment(bolt11, wallet.config, { logger })
|
||||
logger.ok('payment successful:', `payment_hash=${hash}`, `preimage=${preimage}`)
|
||||
} catch (err) {
|
||||
const message = err.message || err.toString?.()
|
||||
logger.error('payment failed:', `payment_hash=${hash}`, message)
|
||||
throw err
|
||||
}
|
||||
}, [me, wallet, config, logger, status])
|
||||
|
||||
const setPriority = useCallback(async (priority) => {
|
||||
if (_isConfigured && priority !== config.priority) {
|
||||
try {
|
||||
await saveConfig({ ...config, priority }, { logger, priorityOnly: true })
|
||||
} catch (err) {
|
||||
toaster.danger(`failed to change priority of ${wallet.name} wallet: ${err.message}`)
|
||||
}
|
||||
}
|
||||
}, [wallet, config, toaster])
|
||||
|
||||
const save = useCallback(async (newConfig) => {
|
||||
await saveConfig(newConfig, { logger })
|
||||
}, [saveConfig, me, logger])
|
||||
|
||||
// delete is a reserved keyword
|
||||
const delete_ = useCallback(async (options) => {
|
||||
try {
|
||||
await clearConfig({ logger, ...options })
|
||||
} catch (err) {
|
||||
const message = err.message || err.toString?.()
|
||||
logger.error(message)
|
||||
throw err
|
||||
}
|
||||
}, [clearConfig, logger, disablePayments])
|
||||
|
||||
const deleteLogs_ = useCallback(async (options) => {
|
||||
// first argument is to override the wallet
|
||||
return await deleteLogs(options)
|
||||
}, [deleteLogs])
|
||||
}, [wallet, logger])
|
||||
|
||||
if (!wallet) return null
|
||||
|
||||
// Assign everything to wallet object so every function that is passed this wallet object in this
|
||||
// `useWallet` hook has access to all others via the reference to it.
|
||||
// Essentially, you can now use functions like `enablePayments` _inside_ of functions that are
|
||||
// called by `useWallet` even before enablePayments is defined and not only in functions
|
||||
// that use the return value of `useWallet`.
|
||||
wallet.isConfigured = _isConfigured
|
||||
wallet.enablePayments = enablePayments
|
||||
wallet.disablePayments = disablePayments
|
||||
wallet.canSend = !!wallet.sendPayment
|
||||
wallet.canReceive = !!wallet.createInvoice
|
||||
wallet.config = config
|
||||
wallet.save = save
|
||||
wallet.delete = delete_
|
||||
wallet.deleteLogs = deleteLogs_
|
||||
wallet.setPriority = setPriority
|
||||
wallet.hasConfig = hasConfig
|
||||
wallet.status = status
|
||||
wallet.enabled = enabled
|
||||
wallet.priority = priority
|
||||
wallet.logger = logger
|
||||
|
||||
// can't assign sendPayment to wallet object because it already exists
|
||||
// as an imported function and thus can't be overwritten
|
||||
return { ...wallet, sendPayment }
|
||||
}
|
||||
|
||||
function extractConfig (fields, config, client) {
|
||||
return Object.entries(config).reduce((acc, [key, value]) => {
|
||||
const field = fields.find(({ name }) => name === key)
|
||||
|
||||
// filter server config which isn't specified as wallet fields
|
||||
// (we allow autowithdraw members to pass validation)
|
||||
if (client && key === 'id') return acc
|
||||
|
||||
// field might not exist because config.enabled doesn't map to a wallet field
|
||||
if (!field || (client ? isClientField(field) : isServerField(field))) {
|
||||
return {
|
||||
...acc,
|
||||
[key]: value
|
||||
}
|
||||
} else {
|
||||
return acc
|
||||
}
|
||||
}, {})
|
||||
}
|
||||
|
||||
export function isServerField (f) {
|
||||
return f.serverOnly || !f.clientOnly
|
||||
}
|
||||
|
||||
export function isClientField (f) {
|
||||
return f.clientOnly || !f.serverOnly
|
||||
}
|
||||
|
||||
function extractClientConfig (fields, config) {
|
||||
return extractConfig(fields, config, true)
|
||||
}
|
||||
|
||||
function extractServerConfig (fields, config) {
|
||||
return extractConfig(fields, config, false)
|
||||
}
|
||||
|
||||
function useConfig (wallet) {
|
||||
const { me } = useMe()
|
||||
|
||||
const storageKey = getStorageKey(wallet?.name, me)
|
||||
const [clientConfig, setClientConfig, clearClientConfig] = useClientConfig(storageKey, {})
|
||||
|
||||
const [serverConfig, setServerConfig, clearServerConfig] = useServerConfig(wallet)
|
||||
|
||||
const hasClientConfig = !!wallet?.sendPayment
|
||||
const hasServerConfig = !!wallet?.walletType
|
||||
|
||||
let config = {}
|
||||
if (hasClientConfig) config = clientConfig
|
||||
if (hasServerConfig) {
|
||||
const { enabled, priority } = config || {}
|
||||
config = {
|
||||
...config,
|
||||
...serverConfig
|
||||
}
|
||||
// wallet is enabled if enabled is set in client or server config
|
||||
config.enabled ||= enabled
|
||||
// priority might only be set on client or server
|
||||
// ie. if send+recv is available but only one is configured
|
||||
config.priority ||= priority
|
||||
}
|
||||
|
||||
const saveConfig = useCallback(async (newConfig, { logger, priorityOnly }) => {
|
||||
// NOTE:
|
||||
// verifying the client/server configuration before saving it
|
||||
// prevents unsetting just one configuration if both are set.
|
||||
// This means there is no way of unsetting just one configuration
|
||||
// since 'detach' detaches both.
|
||||
// Not optimal UX but the trade-off is saving invalid configurations
|
||||
// and maybe it's not that big of an issue.
|
||||
if (hasClientConfig) {
|
||||
let newClientConfig = extractClientConfig(wallet.fields, newConfig)
|
||||
|
||||
let valid = true
|
||||
try {
|
||||
const transformedConfig = await walletValidate(wallet, newClientConfig)
|
||||
if (transformedConfig) {
|
||||
newClientConfig = Object.assign(newClientConfig, transformedConfig)
|
||||
}
|
||||
// these are stored on the server
|
||||
delete newClientConfig.autoWithdrawMaxFeePercent
|
||||
delete newClientConfig.autoWithdrawThreshold
|
||||
delete newClientConfig.autoWithdrawMaxFeeTotal
|
||||
} catch {
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (valid) {
|
||||
if (priorityOnly) {
|
||||
setClientConfig(newClientConfig)
|
||||
} else {
|
||||
try {
|
||||
// XXX: testSendPayment can return a new config (e.g. lnc)
|
||||
const newerConfig = await wallet.testSendPayment?.(newConfig, { me, logger })
|
||||
if (newerConfig) {
|
||||
newClientConfig = Object.assign(newClientConfig, newerConfig)
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(err.message)
|
||||
throw err
|
||||
}
|
||||
|
||||
setClientConfig(newClientConfig)
|
||||
logger.ok(wallet.isConfigured ? 'payment details updated' : 'wallet attached for payments')
|
||||
if (newConfig.enabled) wallet.enablePayments()
|
||||
else wallet.disablePayments()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasServerConfig) {
|
||||
let newServerConfig = extractServerConfig(wallet.fields, newConfig)
|
||||
|
||||
let valid = true
|
||||
try {
|
||||
const transformedConfig = await walletValidate(wallet, newServerConfig)
|
||||
if (transformedConfig) {
|
||||
newServerConfig = Object.assign(newServerConfig, transformedConfig)
|
||||
}
|
||||
} catch {
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (valid) await setServerConfig(newServerConfig, { priorityOnly })
|
||||
}
|
||||
}, [hasClientConfig, hasServerConfig, setClientConfig, setServerConfig, wallet])
|
||||
|
||||
const clearConfig = useCallback(async ({ logger, clientOnly }) => {
|
||||
if (hasClientConfig) {
|
||||
clearClientConfig()
|
||||
wallet.disablePayments()
|
||||
logger.ok('wallet detached for payments')
|
||||
}
|
||||
if (hasServerConfig && !clientOnly) await clearServerConfig()
|
||||
}, [hasClientConfig, hasServerConfig, clearClientConfig, clearServerConfig, wallet])
|
||||
|
||||
return [config, saveConfig, clearConfig]
|
||||
}
|
||||
|
||||
function isConfigured ({ fields, config }) {
|
||||
if (!config || !fields) return false
|
||||
|
||||
// a wallet is configured if all of its required fields are set
|
||||
let val = fields.every(f => {
|
||||
return f.optional ? true : !!config?.[f.name]
|
||||
})
|
||||
|
||||
// however, a wallet is not configured if all fields are optional and none are set
|
||||
// since that usually means that one of them is required
|
||||
if (val && fields.length > 0) {
|
||||
val = !(fields.every(f => f.optional) && fields.every(f => !config?.[f.name]))
|
||||
}
|
||||
|
||||
return val
|
||||
}
|
||||
|
||||
function useServerConfig (wallet) {
|
||||
const client = useApolloClient()
|
||||
const { me } = useMe()
|
||||
|
||||
const { data, refetch: refetchConfig } = useQuery(WALLET_BY_TYPE, { variables: { type: wallet?.walletType }, skip: !wallet?.walletType })
|
||||
|
||||
const walletId = data?.walletByType?.id
|
||||
const serverConfig = {
|
||||
id: walletId,
|
||||
priority: data?.walletByType?.priority,
|
||||
enabled: data?.walletByType?.enabled,
|
||||
...data?.walletByType?.wallet
|
||||
}
|
||||
delete serverConfig.__typename
|
||||
|
||||
const autowithdrawSettings = autowithdrawInitial({ me })
|
||||
const config = { ...serverConfig, ...autowithdrawSettings }
|
||||
|
||||
const saveConfig = useCallback(async ({
|
||||
autoWithdrawThreshold,
|
||||
autoWithdrawMaxFeePercent,
|
||||
autoWithdrawMaxFeeTotal,
|
||||
priority,
|
||||
enabled,
|
||||
...config
|
||||
}, { priorityOnly }) => {
|
||||
try {
|
||||
const mutation = generateMutation(wallet)
|
||||
return await client.mutate({
|
||||
mutation,
|
||||
variables: {
|
||||
...config,
|
||||
id: walletId,
|
||||
settings: {
|
||||
autoWithdrawThreshold: Number(autoWithdrawThreshold),
|
||||
autoWithdrawMaxFeePercent: Number(autoWithdrawMaxFeePercent),
|
||||
autoWithdrawMaxFeeTotal: Number(autoWithdrawMaxFeeTotal),
|
||||
priority,
|
||||
enabled
|
||||
},
|
||||
priorityOnly
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
client.refetchQueries({ include: ['WalletLogs'] })
|
||||
refetchConfig()
|
||||
}
|
||||
}, [client, walletId])
|
||||
|
||||
const clearConfig = useCallback(async () => {
|
||||
// only remove wallet if there is a wallet to remove
|
||||
if (!walletId) return
|
||||
|
||||
try {
|
||||
await client.mutate({
|
||||
mutation: REMOVE_WALLET,
|
||||
variables: { id: walletId }
|
||||
})
|
||||
} finally {
|
||||
client.refetchQueries({ include: ['WalletLogs'] })
|
||||
refetchConfig()
|
||||
}
|
||||
}, [client, walletId])
|
||||
|
||||
return [config, saveConfig, clearConfig]
|
||||
}
|
||||
|
||||
function generateMutation (wallet) {
|
||||
const resolverName = generateResolverName(wallet.walletField)
|
||||
|
||||
let headerArgs = '$id: ID, '
|
||||
headerArgs += wallet.fields
|
||||
.filter(isServerField)
|
||||
.map(f => {
|
||||
let arg = `$${f.name}: String`
|
||||
if (!f.optional) {
|
||||
arg += '!'
|
||||
}
|
||||
return arg
|
||||
}).join(', ')
|
||||
headerArgs += ', $settings: AutowithdrawSettings!, $priorityOnly: Boolean'
|
||||
|
||||
let inputArgs = 'id: $id, '
|
||||
inputArgs += wallet.fields
|
||||
.filter(isServerField)
|
||||
.map(f => `${f.name}: $${f.name}`).join(', ')
|
||||
inputArgs += ', settings: $settings, priorityOnly: $priorityOnly'
|
||||
|
||||
return gql`mutation ${resolverName}(${headerArgs}) {
|
||||
${resolverName}(${inputArgs})
|
||||
}`
|
||||
}
|
||||
|
||||
export function getWalletByName (name) {
|
||||
return walletDefs.find(def => def.name === name)
|
||||
}
|
||||
|
||||
export function getWalletByType (type) {
|
||||
return walletDefs.find(def => def.walletType === type)
|
||||
}
|
||||
|
||||
export function getEnabledWallet (me) {
|
||||
return walletDefs
|
||||
.filter(def => !!def.sendPayment)
|
||||
.map(def => {
|
||||
// populate definition with properties from useWallet that are required for sorting
|
||||
const key = getStorageKey(def.name, me)
|
||||
const config = SSR ? null : JSON.parse(window?.localStorage.getItem(key))
|
||||
const priority = config?.priority
|
||||
return { ...def, config, priority }
|
||||
})
|
||||
.filter(({ config }) => config?.enabled)
|
||||
.sort(walletPrioritySort)[0]
|
||||
}
|
||||
|
||||
export function walletPrioritySort (w1, w2) {
|
||||
const delta = w1.priority - w2.priority
|
||||
// delta is NaN if either priority is undefined
|
||||
if (!Number.isNaN(delta) && delta !== 0) return delta
|
||||
|
||||
// if one wallet has a priority but the other one doesn't, the one with the priority comes first
|
||||
if (w1.priority !== undefined && w2.priority === undefined) return -1
|
||||
if (w1.priority === undefined && w2.priority !== undefined) return 1
|
||||
|
||||
// both wallets have no priority set, falling back to other methods
|
||||
|
||||
// if both wallets have an id, use that as tie breaker
|
||||
// since that's the order in which autowithdrawals are attempted
|
||||
if (w1.config?.id && w2.config?.id) return Number(w1.config.id) - Number(w2.config.id)
|
||||
|
||||
// else we will use the card title as tie breaker
|
||||
return w1.card.title < w2.card.title ? -1 : 1
|
||||
}
|
||||
|
||||
export function useWallets () {
|
||||
const wallets = walletDefs.map(def => useWallet(def.name))
|
||||
|
||||
const resetClient = useCallback(async (wallet) => {
|
||||
for (const w of wallets) {
|
||||
if (w.canSend) {
|
||||
await w.delete({ clientOnly: true })
|
||||
}
|
||||
await w.deleteLogs({ clientOnly: true })
|
||||
}
|
||||
}, [wallets])
|
||||
|
||||
return { wallets, resetClient }
|
||||
}
|
||||
|
||||
function getStorageKey (name, me) {
|
||||
let storageKey = `wallet:${name}`
|
||||
|
||||
// WebLN has no credentials we need to scope to users
|
||||
// so we can use the same storage key for all users
|
||||
if (me && name !== 'webln') {
|
||||
storageKey = `${storageKey}:${me.id}`
|
||||
}
|
||||
|
||||
return storageKey
|
||||
}
|
||||
|
||||
function enableWallet (name, me) {
|
||||
const key = getStorageKey(name, me)
|
||||
const config = JSON.parse(window.localStorage.getItem(key)) || {}
|
||||
config.enabled = true
|
||||
window.localStorage.setItem(key, JSON.stringify(config))
|
||||
}
|
||||
|
||||
function disableWallet (name, me) {
|
||||
const key = getStorageKey(name, me)
|
||||
const config = JSON.parse(window.localStorage.getItem(key)) || {}
|
||||
config.enabled = false
|
||||
window.localStorage.setItem(key, JSON.stringify(config))
|
||||
}
|
||||
|
|
|
@ -1,14 +1,22 @@
|
|||
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 fields = [
|
||||
{
|
||||
name: 'address',
|
||||
label: 'lightning address',
|
||||
type: 'text',
|
||||
autoComplete: 'off'
|
||||
autoComplete: 'off',
|
||||
serverOnly: true,
|
||||
validate: lightningAddressValidator.test({
|
||||
name: 'address',
|
||||
test: addr => !addr.toLowerCase().endsWith('@stacker.news'),
|
||||
message: 'automated withdrawals must be external'
|
||||
})
|
||||
}
|
||||
]
|
||||
|
||||
|
@ -17,9 +25,3 @@ export const card = {
|
|||
subtitle: 'autowithdraw to a lightning address',
|
||||
badges: ['receive']
|
||||
}
|
||||
|
||||
export const fieldValidation = lnAddrAutowithdrawSchema
|
||||
|
||||
export const walletType = 'LIGHTNING_ADDRESS'
|
||||
|
||||
export const walletField = 'walletLightningAddress'
|
||||
|
|
|
@ -1,12 +1,36 @@
|
|||
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 fields = [
|
||||
{
|
||||
name: 'url',
|
||||
label: 'lnbits url',
|
||||
type: 'text'
|
||||
type: 'text',
|
||||
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',
|
||||
|
@ -14,7 +38,8 @@ export const fields = [
|
|||
type: 'password',
|
||||
optional: 'for receiving',
|
||||
serverOnly: true,
|
||||
editable: false
|
||||
requiredWithout: 'adminKey',
|
||||
validate: string().hex().length(32)
|
||||
},
|
||||
{
|
||||
name: 'adminKey',
|
||||
|
@ -22,7 +47,8 @@ export const fields = [
|
|||
type: 'password',
|
||||
optional: 'for sending',
|
||||
clientOnly: true,
|
||||
editable: false
|
||||
requiredWithout: 'invoiceKey',
|
||||
validate: string().hex().length(32)
|
||||
}
|
||||
]
|
||||
|
||||
|
@ -31,9 +57,3 @@ export const card = {
|
|||
subtitle: 'use [LNbits](https://lnbits.com/) for payments',
|
||||
badges: ['send', 'receive']
|
||||
}
|
||||
|
||||
export const fieldValidation = lnbitsSchema
|
||||
|
||||
export const walletType = 'LNBITS'
|
||||
|
||||
export const walletField = 'walletLNbits'
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { InvoiceCanceledError, InvoiceExpiredError } from '@/components/payment'
|
||||
import { InvoiceCanceledError, InvoiceExpiredError } from '@/wallets/errors'
|
||||
import { bolt11Tags } from '@/lib/bolt11'
|
||||
import { Mutex } from 'async-mutex'
|
||||
export * from 'wallets/lnc'
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
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 fields = [
|
||||
{
|
||||
|
@ -8,25 +11,50 @@ export const fields = [
|
|||
label: 'pairing phrase',
|
||||
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
|
||||
editable: false,
|
||||
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',
|
||||
type: 'text',
|
||||
optional: true,
|
||||
hidden: true
|
||||
hidden: true,
|
||||
clientOnly: true,
|
||||
generated: true,
|
||||
validate: string()
|
||||
},
|
||||
{
|
||||
name: 'remoteKey',
|
||||
type: 'text',
|
||||
optional: true,
|
||||
hidden: true
|
||||
hidden: true,
|
||||
clientOnly: true,
|
||||
generated: true,
|
||||
validate: string()
|
||||
},
|
||||
{
|
||||
name: 'serverHost',
|
||||
type: 'text',
|
||||
optional: true,
|
||||
hidden: true
|
||||
hidden: true,
|
||||
clientOnly: true,
|
||||
generated: true,
|
||||
validate: string()
|
||||
}
|
||||
]
|
||||
|
||||
|
@ -35,5 +63,3 @@ export const card = {
|
|||
subtitle: 'use Lightning Node Connect for LND payments',
|
||||
badges: ['send', 'budgetable']
|
||||
}
|
||||
|
||||
export const fieldValidation = lncSchema
|
||||
|
|
|
@ -1,6 +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 fields = [
|
||||
{
|
||||
|
@ -9,7 +12,9 @@ export const fields = [
|
|||
type: 'text',
|
||||
placeholder: '55.5.555.55:10001',
|
||||
hint: 'tor or clearnet',
|
||||
clear: true
|
||||
clear: true,
|
||||
serverOnly: true,
|
||||
validate: string().socket()
|
||||
},
|
||||
{
|
||||
name: 'macaroon',
|
||||
|
@ -21,7 +26,13 @@ export const fields = [
|
|||
type: 'text',
|
||||
placeholder: 'AgEDbG5kAlgDChCn7YgfWX7uTkQQgXZ2uahNEgEwGhYKB2FkZHJlc3MSBHJlYWQSBXdyaXRlGhcKCGludm9pY2VzEgRyZWFkEgV3cml0ZRoPCgdvbmNoYWluEgRyZWFkAAAGIJkMBrrDV0npU90JV0TGNJPrqUD8m2QYoTDjolaL6eBs',
|
||||
hint: 'hex or base64 encoded',
|
||||
clear: true
|
||||
clear: 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',
|
||||
|
@ -30,7 +41,9 @@ 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,
|
||||
serverOnly: true,
|
||||
validate: string().hexOrBase64()
|
||||
}
|
||||
]
|
||||
|
||||
|
@ -39,9 +52,3 @@ export const card = {
|
|||
subtitle: 'autowithdraw to your Lightning Labs node',
|
||||
badges: ['receive']
|
||||
}
|
||||
|
||||
export const fieldValidation = LNDAutowithdrawSchema
|
||||
|
||||
export const walletType = 'LND'
|
||||
|
||||
export const walletField = 'walletLND'
|
||||
|
|
|
@ -1,9 +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 fields = [
|
||||
{
|
||||
|
@ -12,7 +14,8 @@ export const fields = [
|
|||
type: 'password',
|
||||
optional: 'for sending',
|
||||
clientOnly: true,
|
||||
editable: false
|
||||
requiredWithout: 'nwcUrlRecv',
|
||||
validate: string().nwcUrl()
|
||||
},
|
||||
{
|
||||
name: 'nwcUrlRecv',
|
||||
|
@ -20,7 +23,8 @@ export const fields = [
|
|||
type: 'password',
|
||||
optional: 'for receiving',
|
||||
serverOnly: true,
|
||||
editable: false
|
||||
requiredWithout: 'nwcUrl',
|
||||
validate: string().nwcUrl()
|
||||
}
|
||||
]
|
||||
|
||||
|
@ -30,12 +34,6 @@ export const card = {
|
|||
badges: ['send', 'receive', 'budgetable']
|
||||
}
|
||||
|
||||
export const fieldValidation = nwcSchema
|
||||
|
||||
export const walletType = 'NWC'
|
||||
|
||||
export const walletField = 'walletNWC'
|
||||
|
||||
export async function nwcCall ({ nwcUrl, method, params }, { logger, timeout } = {}) {
|
||||
const { relayUrl, walletPubkey, secret } = parseNwcUrl(nwcUrl)
|
||||
|
||||
|
|
|
@ -1,13 +1,16 @@
|
|||
import { phoenixdSchema } from '@/lib/validate'
|
||||
import { string } from '@/lib/yup'
|
||||
|
||||
export const name = 'phoenixd'
|
||||
export const walletType = 'PHOENIXD'
|
||||
export const walletField = 'walletPhoenixd'
|
||||
|
||||
// configure wallet fields
|
||||
export const fields = [
|
||||
{
|
||||
name: 'url',
|
||||
label: 'url',
|
||||
type: 'text'
|
||||
type: 'text',
|
||||
validate: string().url().trim()
|
||||
},
|
||||
{
|
||||
name: 'primaryPassword',
|
||||
|
@ -16,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',
|
||||
|
@ -25,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()
|
||||
}
|
||||
]
|
||||
|
||||
|
@ -35,11 +40,3 @@ export const card = {
|
|||
subtitle: 'use [phoenixd](https://phoenix.acinq.co/server) for payments',
|
||||
badges: ['send', 'receive']
|
||||
}
|
||||
|
||||
// phoenixd::TODO
|
||||
// set validation schema
|
||||
export const fieldValidation = phoenixdSchema
|
||||
|
||||
export const walletType = 'PHOENIXD'
|
||||
|
||||
export const walletField = 'walletPhoenixd'
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
// import server side wallets
|
||||
import * as lnd from 'wallets/lnd/server'
|
||||
import * as cln from 'wallets/cln/server'
|
||||
import * as lnAddr from 'wallets/lightning-address/server'
|
||||
|
@ -5,13 +6,20 @@ import * as lnbits from 'wallets/lnbits/server'
|
|||
import * as nwc from 'wallets/nwc/server'
|
||||
import * as phoenixd from 'wallets/phoenixd/server'
|
||||
import * as blink from 'wallets/blink/server'
|
||||
|
||||
// we import only the metadata of client side wallets
|
||||
import * as lnc from 'wallets/lnc'
|
||||
import * as webln from 'wallets/webln'
|
||||
|
||||
import { addWalletLog } from '@/api/resolvers/wallet'
|
||||
import walletDefs from 'wallets/server'
|
||||
import { parsePaymentRequest } from 'ln-service'
|
||||
import { toPositiveNumber } from '@/lib/validate'
|
||||
import { PAID_ACTION_TERMINAL_STATES } from '@/lib/constants'
|
||||
import { withTimeout } from '@/lib/time'
|
||||
export default [lnd, cln, lnAddr, lnbits, nwc, phoenixd, blink]
|
||||
import { canReceive } from './common'
|
||||
|
||||
export default [lnd, cln, lnAddr, lnbits, nwc, phoenixd, blink, lnc, webln]
|
||||
|
||||
const MAX_PENDING_INVOICES_PER_WALLET = 25
|
||||
|
||||
|
@ -34,6 +42,10 @@ export async function createInvoice (userId, { msats, description, descriptionHa
|
|||
for (const wallet of wallets) {
|
||||
const w = walletDefs.find(w => w.walletType === wallet.type)
|
||||
try {
|
||||
if (!canReceive({ def: w, config: wallet.wallet })) {
|
||||
continue
|
||||
}
|
||||
|
||||
const { walletType, walletField, createInvoice } = w
|
||||
|
||||
const walletFull = await models.wallet.findFirst({
|
||||
|
@ -74,6 +86,7 @@ export async function createInvoice (userId, { msats, description, descriptionHa
|
|||
if (pendingWithdrawals + pendingForwards >= MAX_PENDING_INVOICES_PER_WALLET) {
|
||||
throw new Error('wallet has too many pending invoices')
|
||||
}
|
||||
console.log('use wallet', walletType)
|
||||
|
||||
const invoice = await withTimeout(
|
||||
createInvoice({
|
||||
|
|
|
@ -0,0 +1,110 @@
|
|||
/*
|
||||
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, vaultEntrySchema } from '@/lib/validate'
|
||||
import * as Yup from '@/lib/yup'
|
||||
import { canReceive } from './common'
|
||||
|
||||
export default async function validateWallet (walletDef, data,
|
||||
{ yupOptions = { abortEarly: true }, topLevel = true, serverSide = false, skipGenerated = false } = {}) {
|
||||
let schema = composeWalletSchema(walletDef, serverSide, skipGenerated)
|
||||
|
||||
if (canReceive({ def: walletDef, config: data })) {
|
||||
schema = schema.concat(autowithdrawSchemaMembers)
|
||||
}
|
||||
|
||||
await schema.validate(data, yupOptions)
|
||||
|
||||
const casted = schema.cast(data, { assert: false })
|
||||
if (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, serverSide, skipGenerated) {
|
||||
const { fields } = walletDef
|
||||
|
||||
const vaultEntrySchemas = { required: [], optional: [] }
|
||||
const schemaShape = fields.reduce((acc, field) => {
|
||||
const { name, validate, optional, generated, clientOnly, requiredWithout } = field
|
||||
|
||||
if (generated && skipGenerated) {
|
||||
return acc
|
||||
}
|
||||
|
||||
if (clientOnly && serverSide) {
|
||||
// For server-side validation, accumulate clientOnly fields as vaultEntries
|
||||
vaultEntrySchemas[optional ? 'optional' : 'required'].push(vaultEntrySchema(name))
|
||||
} else {
|
||||
acc[name] = createFieldSchema(name, validate)
|
||||
|
||||
if (!optional) {
|
||||
acc[name] = acc[name].required('required')
|
||||
} else if (requiredWithout) {
|
||||
// if we are the server, the pairSetting will be in the vaultEntries array
|
||||
acc[name] = acc[name].when([serverSide ? 'vaultEntries' : requiredWithout], ([pairSetting], schema) => {
|
||||
if (!pairSetting || (serverSide && !pairSetting.some(v => v.key === requiredWithout))) {
|
||||
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
|
||||
}, {})
|
||||
|
||||
// Finalize the vaultEntries schema if it exists
|
||||
if (vaultEntrySchemas.required.length > 0 || vaultEntrySchemas.optional.length > 0) {
|
||||
schemaShape.vaultEntries = Yup.array().equalto(vaultEntrySchemas)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
|
@ -1,3 +1,5 @@
|
|||
import { useEffect } from 'react'
|
||||
import { SSR } from '@/lib/constants'
|
||||
export * from 'wallets/webln'
|
||||
|
||||
export const sendPayment = async (bolt11) => {
|
||||
|
@ -19,3 +21,32 @@ export const sendPayment = async (bolt11) => {
|
|||
|
||||
return response.preimage
|
||||
}
|
||||
|
||||
export function isAvailable () {
|
||||
return !SSR && window?.weblnEnabled
|
||||
}
|
||||
|
||||
export function WebLnProvider ({ children }) {
|
||||
useEffect(() => {
|
||||
const onEnable = () => {
|
||||
window.weblnEnabled = true
|
||||
}
|
||||
|
||||
const onDisable = () => {
|
||||
window.weblnEnabled = false
|
||||
}
|
||||
|
||||
if (!window.webln) onDisable()
|
||||
else onEnable()
|
||||
|
||||
window.addEventListener('webln:enabled', onEnable)
|
||||
// event is not fired by Alby browser extension but added here for sake of completeness
|
||||
window.addEventListener('webln:disabled', onDisable)
|
||||
return () => {
|
||||
window.removeEventListener('webln:enabled', onEnable)
|
||||
window.removeEventListener('webln:disabled', onDisable)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return children
|
||||
}
|
||||
|
|
|
@ -1,48 +1,17 @@
|
|||
import { useEffect } from 'react'
|
||||
import { useWallet } from 'wallets'
|
||||
|
||||
export const name = 'webln'
|
||||
export const walletType = 'WEBLN'
|
||||
export const walletField = 'walletWebLN'
|
||||
|
||||
export const validate = ({ enabled }) => {
|
||||
if (enabled && typeof window !== 'undefined' && !window?.webln) {
|
||||
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',
|
||||
badges: ['send']
|
||||
}
|
||||
|
||||
export default function WebLnProvider ({ children }) {
|
||||
const wallet = useWallet(name)
|
||||
|
||||
useEffect(() => {
|
||||
const onEnable = () => {
|
||||
wallet.enablePayments()
|
||||
}
|
||||
|
||||
const onDisable = () => {
|
||||
wallet.disablePayments()
|
||||
}
|
||||
|
||||
window.addEventListener('webln:enabled', onEnable)
|
||||
// event is not fired by Alby browser extension but added here for sake of completeness
|
||||
window.addEventListener('webln:disabled', onDisable)
|
||||
return () => {
|
||||
window.removeEventListener('webln:enabled', onEnable)
|
||||
window.removeEventListener('webln:disabled', onDisable)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return children
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue