Merge pull request #1507 from stackernews/wallet-fantasy-refactor

Fantasy wallet refactor
This commit is contained in:
Keyan 2024-11-03 09:10:01 -06:00 committed by GitHub
commit e375cc7c76
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
78 changed files with 2605 additions and 1452 deletions

View File

@ -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]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

75
api/resolvers/vault.js Normal file
View File

@ -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
}
}
}

View File

@ -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 = {

View File

@ -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]

View File

@ -183,6 +183,8 @@ export default gql`
autoWithdrawThreshold: Int
autoWithdrawMaxFeePercent: Float
autoWithdrawMaxFeeTotal: Int
vaultKeyHash: String
walletsUpdatedAt: Date
}
type UserOptional {

29
api/typeDefs/vault.js Normal file
View File

@ -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
}
`

View File

@ -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 {

View File

@ -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>

View File

@ -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>
)

View File

@ -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>
)
}

View File

@ -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()

View File

@ -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}
/>
)
}

View File

@ -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;
}

View File

@ -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'

View File

@ -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]

View File

@ -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)

View File

@ -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}>

View File

@ -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: '/' })
}}

View File

@ -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()

View File

@ -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>}

View File

@ -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
? {}
: {

View File

@ -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])

View File

@ -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

View File

@ -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'
/*

View File

@ -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

View File

@ -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
}
}

View File

@ -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)
}

View File

@ -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>

View File

@ -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>

View File

@ -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 }
}

View File

@ -48,6 +48,8 @@ ${STREAK_FIELDS}
upvotePopover
wildWestMode
disableFreebies
vaultKeyHash
walletsUpdatedAt
}
optional {
isContributor

44
fragments/vault.js Normal file
View File

@ -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)
}
`

View File

@ -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)
}
`

View File

@ -99,6 +99,13 @@ function getClient (uri) {
Fact: {
keyFields: ['id', 'type']
},
Wallet: {
fields: {
vaultEntries: {
replace: true
}
}
},
Query: {
fields: {
sub: {

View File

@ -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 } })
}
}

20
lib/hex.js Normal file
View File

@ -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
}

View File

@ -1,4 +1,4 @@
import { string, ValidationError, number, object, array, addMethod, boolean, date } from 'yup'
import { string, ValidationError, number, object, array, boolean, date } from './yup'
import {
BOOST_MIN, MAX_POLL_CHOICE_LENGTH, MAX_TITLE_LENGTH, MAX_POLL_NUM_CHOICES,
MIN_POLL_NUM_CHOICES, MAX_FORWARDS, BOOST_MULT, MAX_TERRITORY_DESC_LENGTH, POST_TYPES,
@ -6,19 +6,16 @@ import {
} from './constants'
import { SUPPORTED_CURRENCIES } from './currency'
import { NOSTR_MAX_RELAY_NUM, NOSTR_PUBKEY_BECH32, NOSTR_PUBKEY_HEX } from './nostr'
import { msatsToSats, numWithUnits, abbrNum, ensureB64, B64_URL_REGEX, HEX_REGEX } from './format'
import { msatsToSats, numWithUnits, abbrNum } from './format'
import * as usersFragments from '@/fragments/users'
import * as subsFragments from '@/fragments/subs'
import { isInvoicableMacaroon, isInvoiceMacaroon } from './macaroon'
import { TOR_REGEXP, parseNwcUrl } from './url'
import { datePivot } from './time'
import { decodeRune } from '@/lib/cln'
import bip39Words from './bip39-words'
const { SUB } = subsFragments
const { NAME_QUERY } = usersFragments
export async function ssValidate (schema, data, args) {
export async function validateSchema (schema, data, args) {
try {
if (typeof schema === 'function') {
return await schema(args).validate(data)
@ -33,159 +30,6 @@ export async function ssValidate (schema, data, args) {
}
}
export async function formikValidate (validate, data) {
const result = await validate(data)
if (Object.keys(result).length > 0) {
const [key, message] = Object.entries(result)[0]
throw new Error(`${key}: ${message}`)
}
return result
}
export async function walletValidate (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
})
})

View File

@ -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}`
}

200
lib/yup.js Normal file
View File

@ -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
}
})
})

88
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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>

View File

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

View File

@ -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>
</>
)

View File

@ -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>
)
}

View File

@ -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
? (

View File

@ -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>

View File

@ -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();

View File

@ -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?
}

1
svgs/clipboard-line.svg Normal file
View File

@ -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

1
svgs/qr-code-line.svg Normal file
View File

@ -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

1
svgs/qr-scan-line.svg Normal file
View File

@ -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

1
svgs/refresh-line.svg Normal file
View File

@ -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

View File

@ -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`

View File

@ -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']
}

View File

@ -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'

163
wallets/common.js Normal file
View File

@ -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))
}

118
wallets/config.js Normal file
View File

@ -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 }
}

22
wallets/errors.js Normal file
View File

@ -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'
}
}

53
wallets/graphql.js Normal file
View File

@ -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
}
}`
}

View File

@ -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))
}

View File

@ -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'

View File

@ -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'

View File

@ -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'

View File

@ -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

View File

@ -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'

View File

@ -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)

View File

@ -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'

View File

@ -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({

110
wallets/validate.js Normal file
View File

@ -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
}

View File

@ -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
}

View File

@ -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
}