user vault and server side client wallets
This commit is contained in:
parent
7a942881ed
commit
b70dbeb6d6
|
@ -19,6 +19,7 @@ import chainFee from './chainFee'
|
||||||
import { GraphQLScalarType, Kind } from 'graphql'
|
import { GraphQLScalarType, Kind } from 'graphql'
|
||||||
import { createIntScalar } from 'graphql-scalar'
|
import { createIntScalar } from 'graphql-scalar'
|
||||||
import paidAction from './paidAction'
|
import paidAction from './paidAction'
|
||||||
|
import vault from './vault'
|
||||||
|
|
||||||
const date = new GraphQLScalarType({
|
const date = new GraphQLScalarType({
|
||||||
name: 'Date',
|
name: 'Date',
|
||||||
|
@ -55,4 +56,4 @@ const limit = createIntScalar({
|
||||||
|
|
||||||
export default [user, item, message, wallet, lnurl, notifications, invite, sub,
|
export default [user, item, message, wallet, lnurl, notifications, invite, sub,
|
||||||
upload, search, growth, rewards, referrals, price, admin, blockHeight, chainFee,
|
upload, search, growth, rewards, referrals, price, admin, blockHeight, chainFee,
|
||||||
{ JSONObject }, { Date: date }, { Limit: limit }, paidAction]
|
{ JSONObject }, { Date: date }, { Limit: limit }, paidAction, vault]
|
||||||
|
|
|
@ -0,0 +1,148 @@
|
||||||
|
import { E_VAULT_KEY_EXISTS, GqlAuthenticationError, GqlInputError } from '@/lib/error'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
VaultOwner: {
|
||||||
|
__resolveType: (obj) => obj.type
|
||||||
|
},
|
||||||
|
Query: {
|
||||||
|
getVaultEntry: async (parent, { ownerId, ownerType, key }, { me, models }, info) => {
|
||||||
|
if (!me) throw new GqlAuthenticationError()
|
||||||
|
if (!key) throw new GqlInputError('must have key')
|
||||||
|
checkOwner(info, ownerType)
|
||||||
|
|
||||||
|
const k = await models.vault.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_key_ownerId_ownerType: {
|
||||||
|
key,
|
||||||
|
userId: me.id,
|
||||||
|
ownerId: Number(ownerId),
|
||||||
|
ownerType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return k
|
||||||
|
},
|
||||||
|
getVaultEntries: async (parent, { ownerId, ownerType, keysFilter }, { me, models }, info) => {
|
||||||
|
if (!me) throw new GqlAuthenticationError()
|
||||||
|
checkOwner(info, ownerType)
|
||||||
|
|
||||||
|
const entries = await models.vault.findMany({
|
||||||
|
where: {
|
||||||
|
userId: me.id,
|
||||||
|
ownerId: Number(ownerId),
|
||||||
|
ownerType,
|
||||||
|
key: keysFilter?.length
|
||||||
|
? {
|
||||||
|
in: keysFilter
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Mutation: {
|
||||||
|
setVaultEntry: async (parent, { ownerId, ownerType, key, value, skipIfSet }, { me, models }, info) => {
|
||||||
|
if (!me) throw new GqlAuthenticationError()
|
||||||
|
if (!key) throw new GqlInputError('must have key')
|
||||||
|
if (!value) throw new GqlInputError('must have value')
|
||||||
|
checkOwner(info, ownerType)
|
||||||
|
|
||||||
|
if (skipIfSet) {
|
||||||
|
const existing = await models.vault.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_key_ownerId_ownerType: {
|
||||||
|
userId: me.id,
|
||||||
|
key,
|
||||||
|
ownerId: Number(ownerId),
|
||||||
|
ownerType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (existing) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await models.vault.upsert({
|
||||||
|
where: {
|
||||||
|
userId_key_ownerId_ownerType: {
|
||||||
|
userId: me.id,
|
||||||
|
key,
|
||||||
|
ownerId: Number(ownerId),
|
||||||
|
ownerType
|
||||||
|
}
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
value
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
userId: me.id,
|
||||||
|
ownerId: Number(ownerId),
|
||||||
|
ownerType
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
unsetVaultEntry: async (parent, { ownerId, ownerType, key }, { me, models }, info) => {
|
||||||
|
if (!me) throw new GqlAuthenticationError()
|
||||||
|
if (!key) throw new GqlInputError('must have key')
|
||||||
|
checkOwner(info, ownerType)
|
||||||
|
|
||||||
|
await models.vault.deleteMany({
|
||||||
|
where: {
|
||||||
|
userId: me.id,
|
||||||
|
key,
|
||||||
|
ownerId: Number(ownerId),
|
||||||
|
ownerType
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
clearVault: async (parent, args, { me, models }) => {
|
||||||
|
if (!me) throw new GqlAuthenticationError()
|
||||||
|
|
||||||
|
await models.user.update({
|
||||||
|
where: { id: me.id },
|
||||||
|
data: { vaultKeyHash: '' }
|
||||||
|
})
|
||||||
|
await models.vault.deleteMany({ where: { userId: me.id } })
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
setVaultKeyHash: async (parent, { hash }, { me, models }) => {
|
||||||
|
if (!me) throw new GqlAuthenticationError()
|
||||||
|
if (!hash) throw new GqlInputError('hash required')
|
||||||
|
|
||||||
|
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 {
|
||||||
|
await models.user.update({
|
||||||
|
where: { id: me.id },
|
||||||
|
data: { vaultKeyHash: hash }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures the passed ownerType represent a valid type that extends VaultOwner in the graphql schema.
|
||||||
|
* Throws a GqlInputError otherwise
|
||||||
|
* @param {*} info the graphql resolve info
|
||||||
|
* @param {string} ownerType the ownerType to check
|
||||||
|
* @throws GqlInputError
|
||||||
|
*/
|
||||||
|
function checkOwner (info, ownerType) {
|
||||||
|
const gqltypeDef = info.schema.getType(ownerType)
|
||||||
|
const ownerInterfaces = gqltypeDef?.getInterfaces ? gqltypeDef.getInterfaces() : null
|
||||||
|
if (!ownerInterfaces?.some((iface) => iface.name === 'VaultOwner')) {
|
||||||
|
throw new GqlInputError('owner must implement VaultOwner interface but ' + ownerType + ' does not')
|
||||||
|
}
|
||||||
|
}
|
|
@ -19,7 +19,7 @@ import assertApiKeyNotPermitted from './apiKey'
|
||||||
import { bolt11Tags } from '@/lib/bolt11'
|
import { bolt11Tags } from '@/lib/bolt11'
|
||||||
import { finalizeHodlInvoice } from 'worker/wallet'
|
import { finalizeHodlInvoice } from 'worker/wallet'
|
||||||
import walletDefs from 'wallets/server'
|
import walletDefs from 'wallets/server'
|
||||||
import { generateResolverName, generateTypeDefName } from '@/lib/wallet'
|
import { generateResolverName, generateTypeDefName, isConfigured } from '@/lib/wallet'
|
||||||
import { lnAddrOptions } from '@/lib/lnurl'
|
import { lnAddrOptions } from '@/lib/lnurl'
|
||||||
import { GqlAuthenticationError, GqlAuthorizationError, GqlInputError } from '@/lib/error'
|
import { GqlAuthenticationError, GqlAuthorizationError, GqlInputError } from '@/lib/error'
|
||||||
import { getNodeSockets, getOurPubkey } from '../lnd'
|
import { getNodeSockets, getOurPubkey } from '../lnd'
|
||||||
|
@ -29,10 +29,18 @@ function injectResolvers (resolvers) {
|
||||||
for (const w of walletDefs) {
|
for (const w of walletDefs) {
|
||||||
const resolverName = generateResolverName(w.walletField)
|
const resolverName = generateResolverName(w.walletField)
|
||||||
console.log(resolverName)
|
console.log(resolverName)
|
||||||
|
resolvers.Mutation[resolverName] = async (parent, { settings, priorityOnly, canSend, canReceive, ...data }, { me, models }) => {
|
||||||
|
if (canReceive && !w.createInvoice) {
|
||||||
|
console.warn('Requested to upsert wallet as a receiver, but wallet does not support createInvoice. disabling')
|
||||||
|
canReceive = false
|
||||||
|
}
|
||||||
|
|
||||||
resolvers.Mutation[resolverName] = async (parent, { settings, priorityOnly, ...data }, { me, models }) => {
|
if (!priorityOnly && canReceive) {
|
||||||
// allow transformation of the data on validation (this is optional ... won't do anything if not implemented)
|
// check if the required fields are set
|
||||||
if (!priorityOnly) {
|
if (!isConfigured({ fields: w.fields, config: data, serverOnly: true })) {
|
||||||
|
throw new GqlInputError('missing required fields')
|
||||||
|
}
|
||||||
|
// allow transformation of the data on validation (this is optional ... won't do anything if not implemented)
|
||||||
const validData = await walletValidate(w, { ...data, ...settings })
|
const validData = await walletValidate(w, { ...data, ...settings })
|
||||||
if (validData) {
|
if (validData) {
|
||||||
Object.keys(validData).filter(key => key in data).forEach(key => { data[key] = validData[key] })
|
Object.keys(validData).filter(key => key in data).forEach(key => { data[key] = validData[key] })
|
||||||
|
@ -41,9 +49,19 @@ function injectResolvers (resolvers) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return await upsertWallet({
|
return await upsertWallet({
|
||||||
wallet: { field: w.walletField, type: w.walletType },
|
wallet: {
|
||||||
testCreateInvoice: (data) => w.testCreateInvoice(data, { me, models })
|
field:
|
||||||
}, { settings, data, priorityOnly }, { me, models })
|
w.walletField,
|
||||||
|
type: w.walletType
|
||||||
|
},
|
||||||
|
testCreateInvoice: w.testCreateInvoice ? (data) => w.testCreateInvoice(data, { me, models }) : null
|
||||||
|
}, {
|
||||||
|
settings,
|
||||||
|
data,
|
||||||
|
priorityOnly,
|
||||||
|
canSend,
|
||||||
|
canReceive
|
||||||
|
}, { me, models })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.groupEnd()
|
console.groupEnd()
|
||||||
|
@ -158,14 +176,20 @@ const resolvers = {
|
||||||
})
|
})
|
||||||
return wallet
|
return wallet
|
||||||
},
|
},
|
||||||
wallets: async (parent, args, { me, models }) => {
|
wallets: async (parent, { includeReceivers = true, includeSenders = true, onlyEnabled = false }, { me, models }) => {
|
||||||
if (!me) {
|
if (!me) {
|
||||||
throw new GqlAuthenticationError()
|
throw new GqlAuthenticationError()
|
||||||
}
|
}
|
||||||
|
|
||||||
return await models.wallet.findMany({
|
return await models.wallet.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId: me.id
|
userId: me.id,
|
||||||
|
canReceive: includeReceivers,
|
||||||
|
canSend: includeSenders,
|
||||||
|
enabled: onlyEnabled !== undefined ? onlyEnabled : undefined
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
priority: 'desc'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
@ -627,13 +651,14 @@ export const addWalletLog = async ({ wallet, level, message }, { models }) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function upsertWallet (
|
async function upsertWallet (
|
||||||
{ wallet, testCreateInvoice }, { settings, data, priorityOnly }, { me, models }) {
|
{ wallet, testCreateInvoice },
|
||||||
if (!me) {
|
{ settings, data, priorityOnly, canSend, canReceive },
|
||||||
throw new GqlAuthenticationError()
|
{ me, models }
|
||||||
}
|
) {
|
||||||
|
if (!me) throw new GqlAuthenticationError()
|
||||||
assertApiKeyNotPermitted({ me })
|
assertApiKeyNotPermitted({ me })
|
||||||
|
|
||||||
if (testCreateInvoice && !priorityOnly) {
|
if (testCreateInvoice && !priorityOnly && canReceive) {
|
||||||
try {
|
try {
|
||||||
await testCreateInvoice(data)
|
await testCreateInvoice(data)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
@ -655,70 +680,103 @@ async function upsertWallet (
|
||||||
priority
|
priority
|
||||||
} = settings
|
} = settings
|
||||||
|
|
||||||
const txs = [
|
return await models.$transaction(async (tx) => {
|
||||||
models.user.update({
|
if (canReceive) {
|
||||||
where: { id: me.id },
|
tx.user.update({
|
||||||
data: {
|
where: { id: me.id },
|
||||||
autoWithdrawMaxFeePercent,
|
data: {
|
||||||
autoWithdrawThreshold,
|
autoWithdrawMaxFeePercent,
|
||||||
autoWithdrawMaxFeeTotal
|
autoWithdrawThreshold
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
]
|
}
|
||||||
|
|
||||||
if (id) {
|
let updatedWallet
|
||||||
txs.push(
|
if (id) {
|
||||||
models.wallet.update({
|
const existingWalletTypeRecord = canReceive
|
||||||
|
? await tx[wallet.field].findUnique({
|
||||||
|
where: { walletId: Number(id) }
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
updatedWallet = tx.wallet.update({
|
||||||
where: { id: Number(id), userId: me.id },
|
where: { id: Number(id), userId: me.id },
|
||||||
data: {
|
data: {
|
||||||
enabled,
|
enabled,
|
||||||
priority,
|
priority,
|
||||||
[wallet.field]: {
|
canSend,
|
||||||
update: {
|
canReceive,
|
||||||
where: { walletId: Number(id) },
|
// if send-only config or priority only, don't update the wallet type record
|
||||||
data: walletData
|
...(canReceive && !priorityOnly
|
||||||
}
|
? {
|
||||||
}
|
[wallet.field]: existingWalletTypeRecord
|
||||||
|
? { update: walletData }
|
||||||
|
: { create: walletData }
|
||||||
|
}
|
||||||
|
: {})
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
...(canReceive && !priorityOnly ? { [wallet.field]: true } : {})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
)
|
} else {
|
||||||
} else {
|
updatedWallet = tx.wallet.create({
|
||||||
txs.push(
|
|
||||||
models.wallet.create({
|
|
||||||
data: {
|
data: {
|
||||||
enabled,
|
enabled,
|
||||||
priority,
|
priority,
|
||||||
|
canSend,
|
||||||
|
canReceive,
|
||||||
userId: me.id,
|
userId: me.id,
|
||||||
type: wallet.type,
|
type: wallet.type,
|
||||||
[wallet.field]: {
|
// if send-only config or priority only, don't update the wallet type record
|
||||||
create: walletData
|
...(canReceive && !priorityOnly
|
||||||
}
|
? {
|
||||||
|
[wallet.field]: {
|
||||||
|
create: walletData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: {})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
txs.push(
|
const logs = []
|
||||||
models.walletLog.createMany({
|
if (canReceive) {
|
||||||
data: {
|
logs.push({
|
||||||
userId: me.id,
|
userId: me.id,
|
||||||
wallet: wallet.type,
|
wallet: wallet.type,
|
||||||
level: 'SUCCESS',
|
level: enabled ? 'SUCCESS' : 'INFO',
|
||||||
message: id ? 'receive details updated' : 'wallet attached for receives'
|
message: id ? 'receive details updated' : 'wallet attached for receives'
|
||||||
}
|
})
|
||||||
}),
|
logs.push({
|
||||||
models.walletLog.create({
|
|
||||||
data: {
|
|
||||||
userId: me.id,
|
userId: me.id,
|
||||||
wallet: wallet.type,
|
wallet: wallet.type,
|
||||||
level: enabled ? 'SUCCESS' : 'INFO',
|
level: enabled ? 'SUCCESS' : 'INFO',
|
||||||
message: enabled ? 'receives enabled' : 'receives disabled'
|
message: enabled ? 'receives enabled' : 'receives disabled'
|
||||||
}
|
})
|
||||||
})
|
}
|
||||||
)
|
|
||||||
|
|
||||||
await models.$transaction(txs)
|
if (canSend) {
|
||||||
return true
|
logs.push({
|
||||||
|
userId: me.id,
|
||||||
|
wallet: wallet.type,
|
||||||
|
level: enabled ? 'SUCCESS' : 'INFO',
|
||||||
|
message: id ? 'send details updated' : 'wallet attached for sends'
|
||||||
|
})
|
||||||
|
logs.push({
|
||||||
|
userId: me.id,
|
||||||
|
wallet: wallet.type,
|
||||||
|
level: enabled ? 'SUCCESS' : 'INFO',
|
||||||
|
message: enabled ? 'sends enabled' : 'sends disabled'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.walletLog.createMany({
|
||||||
|
data: logs
|
||||||
|
})
|
||||||
|
|
||||||
|
return updatedWallet
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createWithdrawal (parent, { invoice, maxFee }, { me, models, lnd, headers, walletId = null }) {
|
export async function createWithdrawal (parent, { invoice, maxFee }, { me, models, lnd, headers, walletId = null }) {
|
||||||
|
|
|
@ -18,6 +18,7 @@ import admin from './admin'
|
||||||
import blockHeight from './blockHeight'
|
import blockHeight from './blockHeight'
|
||||||
import chainFee from './chainFee'
|
import chainFee from './chainFee'
|
||||||
import paidAction from './paidAction'
|
import paidAction from './paidAction'
|
||||||
|
import vault from './vault'
|
||||||
|
|
||||||
const common = gql`
|
const common = gql`
|
||||||
type Query {
|
type Query {
|
||||||
|
@ -38,4 +39,4 @@ const common = gql`
|
||||||
`
|
`
|
||||||
|
|
||||||
export default [common, user, item, itemForward, message, wallet, lnurl, notifications, invite,
|
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]
|
||||||
|
|
|
@ -46,7 +46,7 @@ export default gql`
|
||||||
disableFreebies: Boolean
|
disableFreebies: Boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type User {
|
type User implements VaultOwner {
|
||||||
id: ID!
|
id: ID!
|
||||||
createdAt: Date!
|
createdAt: Date!
|
||||||
name: String
|
name: String
|
||||||
|
@ -182,7 +182,11 @@ export default gql`
|
||||||
withdrawMaxFeeDefault: Int!
|
withdrawMaxFeeDefault: Int!
|
||||||
autoWithdrawThreshold: Int
|
autoWithdrawThreshold: Int
|
||||||
autoWithdrawMaxFeePercent: Float
|
autoWithdrawMaxFeePercent: Float
|
||||||
|
<<<<<<< HEAD
|
||||||
autoWithdrawMaxFeeTotal: Int
|
autoWithdrawMaxFeeTotal: Int
|
||||||
|
=======
|
||||||
|
vaultKeyHash: String
|
||||||
|
>>>>>>> 002b1d19 (user vault and server side client wallets)
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserOptional {
|
type UserOptional {
|
||||||
|
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { gql } from 'graphql-tag'
|
||||||
|
|
||||||
|
export default gql`
|
||||||
|
interface VaultOwner {
|
||||||
|
id: ID!
|
||||||
|
}
|
||||||
|
|
||||||
|
type Vault {
|
||||||
|
id: ID!
|
||||||
|
key: String!
|
||||||
|
value: String!
|
||||||
|
createdAt: Date!
|
||||||
|
updatedAt: Date!
|
||||||
|
}
|
||||||
|
|
||||||
|
extend type Query {
|
||||||
|
getVaultEntry(ownerId:ID!, ownerType:String!, key: String!): Vault
|
||||||
|
getVaultEntries(ownerId:ID!, ownerType:String!, keysFilter: [String]): [Vault!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
extend type Mutation {
|
||||||
|
setVaultEntry(ownerId:ID!, ownerType:String!, key: String!, value: String!, skipIfSet: Boolean): Boolean
|
||||||
|
unsetVaultEntry(ownerId:ID!, ownerType:String!, key: String!): Boolean
|
||||||
|
|
||||||
|
clearVault: Boolean
|
||||||
|
setVaultKeyHash(hash: String!): String
|
||||||
|
}
|
||||||
|
`
|
|
@ -1,8 +1,7 @@
|
||||||
import { gql } from 'graphql-tag'
|
import { gql } from 'graphql-tag'
|
||||||
import { fieldToGqlArg, generateResolverName, generateTypeDefName } from '@/lib/wallet'
|
import { fieldToGqlArg, fieldToGqlArgOptional, generateResolverName, generateTypeDefName, isServerField } from '@/lib/wallet'
|
||||||
|
|
||||||
import walletDefs from 'wallets/server'
|
import walletDefs from 'wallets/server'
|
||||||
import { isServerField } from 'wallets'
|
|
||||||
|
|
||||||
function injectTypeDefs (typeDefs) {
|
function injectTypeDefs (typeDefs) {
|
||||||
const injected = [rawTypeDefs(), mutationTypeDefs()]
|
const injected = [rawTypeDefs(), mutationTypeDefs()]
|
||||||
|
@ -14,12 +13,13 @@ function mutationTypeDefs () {
|
||||||
|
|
||||||
const typeDefs = walletDefs.map((w) => {
|
const typeDefs = walletDefs.map((w) => {
|
||||||
let args = 'id: ID, '
|
let args = 'id: ID, '
|
||||||
args += w.fields
|
const serverFields = w.fields
|
||||||
.filter(isServerField)
|
.filter(isServerField)
|
||||||
.map(fieldToGqlArg).join(', ')
|
.map(fieldToGqlArgOptional)
|
||||||
args += ', settings: AutowithdrawSettings!, priorityOnly: Boolean'
|
if (serverFields.length > 0) args += serverFields.join(', ') + ','
|
||||||
|
args += 'settings: AutowithdrawSettings!, priorityOnly: Boolean, canSend: Boolean!, canReceive: Boolean!'
|
||||||
const resolverName = generateResolverName(w.walletField)
|
const resolverName = generateResolverName(w.walletField)
|
||||||
const typeDef = `${resolverName}(${args}): Boolean`
|
const typeDef = `${resolverName}(${args}): Wallet`
|
||||||
console.log(typeDef)
|
console.log(typeDef)
|
||||||
return typeDef
|
return typeDef
|
||||||
})
|
})
|
||||||
|
@ -33,11 +33,15 @@ function rawTypeDefs () {
|
||||||
console.group('injected GraphQL type defs:')
|
console.group('injected GraphQL type defs:')
|
||||||
|
|
||||||
const typeDefs = walletDefs.map((w) => {
|
const typeDefs = walletDefs.map((w) => {
|
||||||
const args = w.fields
|
let args = w.fields
|
||||||
.filter(isServerField)
|
.filter(isServerField)
|
||||||
.map(fieldToGqlArg)
|
.map(fieldToGqlArg)
|
||||||
.map(s => ' ' + s)
|
.map(s => ' ' + s)
|
||||||
.join('\n')
|
.join('\n')
|
||||||
|
if (!args) {
|
||||||
|
// add a placeholder arg so the type is not empty
|
||||||
|
args = ' _empty: Boolean'
|
||||||
|
}
|
||||||
const typeDefName = generateTypeDefName(w.walletType)
|
const typeDefName = generateTypeDefName(w.walletType)
|
||||||
const typeDef = `type ${typeDefName} {\n${args}\n}`
|
const typeDef = `type ${typeDefName} {\n${args}\n}`
|
||||||
console.log(typeDef)
|
console.log(typeDef)
|
||||||
|
@ -63,7 +67,7 @@ const typeDefs = `
|
||||||
numBolt11s: Int!
|
numBolt11s: Int!
|
||||||
connectAddress: String!
|
connectAddress: String!
|
||||||
walletHistory(cursor: String, inc: String): History
|
walletHistory(cursor: String, inc: String): History
|
||||||
wallets: [Wallet!]!
|
wallets(includeReceivers: Boolean, includeSenders: Boolean, onlyEnabled: Boolean): [Wallet!]!
|
||||||
wallet(id: ID!): Wallet
|
wallet(id: ID!): Wallet
|
||||||
walletByType(type: String!): Wallet
|
walletByType(type: String!): Wallet
|
||||||
walletLogs(type: String, from: String, to: String, cursor: String): WalletLog!
|
walletLogs(type: String, from: String, to: String, cursor: String): WalletLog!
|
||||||
|
@ -79,13 +83,15 @@ const typeDefs = `
|
||||||
deleteWalletLogs(wallet: String): Boolean
|
deleteWalletLogs(wallet: String): Boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type Wallet {
|
type Wallet implements VaultOwner {
|
||||||
id: ID!
|
id: ID!
|
||||||
createdAt: Date!
|
createdAt: Date!
|
||||||
type: String!
|
type: String!
|
||||||
enabled: Boolean!
|
enabled: Boolean!
|
||||||
priority: Int!
|
priority: Int!
|
||||||
wallet: WalletDetails!
|
wallet: WalletDetails!
|
||||||
|
canReceive: Boolean!
|
||||||
|
canSend: Boolean!
|
||||||
}
|
}
|
||||||
|
|
||||||
input AutowithdrawSettings {
|
input AutowithdrawSettings {
|
||||||
|
|
|
@ -4,6 +4,6 @@ import Button from 'react-bootstrap/Button'
|
||||||
export default function CancelButton ({ onClick }) {
|
export default function CancelButton ({ onClick }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
return (
|
return (
|
||||||
<Button className='me-4 text-muted nav-link fw-bold' variant='link' onClick={onClick || (() => router.back())}>cancel</Button>
|
<Button className='me-2 text-muted nav-link fw-bold' variant='link' onClick={onClick || (() => router.back())}>cancel</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,264 @@
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { useMe } from './me'
|
||||||
|
import { useShowModal } from './modal'
|
||||||
|
import useVault, { useVaultConfigurator, useVaultMigration } from './use-vault'
|
||||||
|
import { Button, InputGroup } from 'react-bootstrap'
|
||||||
|
import { Form, Input, PasswordInput, SubmitButton } from './form'
|
||||||
|
import bip39Words from '@/lib/bip39-words'
|
||||||
|
import Info from './info'
|
||||||
|
import CancelButton from './cancel-button'
|
||||||
|
import * as yup from 'yup'
|
||||||
|
import { deviceSyncSchema } from '@/lib/validate'
|
||||||
|
import RefreshIcon from '@/svgs/refresh-line.svg'
|
||||||
|
|
||||||
|
export default function DeviceSync () {
|
||||||
|
const { me } = useMe()
|
||||||
|
const [value, setVaultKey, clearVault, disconnectVault] = useVaultConfigurator()
|
||||||
|
const showModal = useShowModal()
|
||||||
|
|
||||||
|
const enabled = !!me?.privates?.vaultKeyHash
|
||||||
|
const connected = !!value?.key
|
||||||
|
|
||||||
|
const migrate = useVaultMigration()
|
||||||
|
const [debugValue, setDebugValue, clearValue] = useVault(me, 'debug')
|
||||||
|
|
||||||
|
const manage = useCallback(async () => {
|
||||||
|
if (enabled && connected) {
|
||||||
|
showModal((onClose) => (
|
||||||
|
<div>
|
||||||
|
<h2>Device sync is enabled!</h2>
|
||||||
|
<p>
|
||||||
|
Sensitive data (like wallet credentials) 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 className='me-2 text-muted nav-link fw-bold' variant='link' onClick={onClose}>close</Button>
|
||||||
|
<Button
|
||||||
|
variant='primary'
|
||||||
|
onClick={() => {
|
||||||
|
disconnectVault()
|
||||||
|
onClose()
|
||||||
|
}}
|
||||||
|
>disconnect
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
showModal((onClose) => (
|
||||||
|
<ConnectForm onClose={onClose} onConnect={onConnect} enabled={enabled} />
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}, [migrate, enabled, connected, value])
|
||||||
|
|
||||||
|
const reset = useCallback(async () => {
|
||||||
|
const schema = yup.object().shape({
|
||||||
|
confirm: yup.string()
|
||||||
|
.oneOf(['yes'], 'you must confirm by typing "yes"')
|
||||||
|
.required('required')
|
||||||
|
})
|
||||||
|
showModal((onClose) => (
|
||||||
|
<div>
|
||||||
|
<h2>Reset device sync</h2>
|
||||||
|
<p>
|
||||||
|
This will delete all encrypted data on the server and disconnect all devices.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
You will need to enter a new passphrase on this and all other devices to sync data again.
|
||||||
|
</p>
|
||||||
|
<Form
|
||||||
|
className='mt-3'
|
||||||
|
initial={{ confirm: '' }}
|
||||||
|
schema={schema}
|
||||||
|
onSubmit={async values => {
|
||||||
|
await clearVault()
|
||||||
|
onClose()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
label='This action cannot be undone. Type `yes` to confirm.'
|
||||||
|
name='confirm'
|
||||||
|
placeholder=''
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
autoComplete='off'
|
||||||
|
/>
|
||||||
|
<div className='d-flex justify-content-between'>
|
||||||
|
<div className='d-flex align-items-center ms-auto'>
|
||||||
|
<CancelButton onClick={onClose} />
|
||||||
|
<SubmitButton variant='danger'>
|
||||||
|
continue
|
||||||
|
</SubmitButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onConnect = useCallback(async (values, formik) => {
|
||||||
|
if (values.passphrase) {
|
||||||
|
try {
|
||||||
|
await setVaultKey(values.passphrase)
|
||||||
|
await migrate()
|
||||||
|
} catch (e) {
|
||||||
|
formik?.setErrors({ passphrase: e.message })
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [setVaultKey, migrate])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className='form-label mt-3'>device sync</div>
|
||||||
|
<div className='mt-2 d-flex align-items-center'>
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
variant='secondary'
|
||||||
|
onClick={manage}
|
||||||
|
>
|
||||||
|
{enabled ? (connected ? 'Manage ' : 'Connect to ') : 'Enable '}
|
||||||
|
device sync
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Info>
|
||||||
|
<p>
|
||||||
|
Device sync uses end-to-end encryption to securely synchronize your data across devices.
|
||||||
|
</p>
|
||||||
|
<p className='text-muted text-sm'>
|
||||||
|
Your sensitive data remains private and inaccessible to our servers while being synced across all your connected devices using only a passphrase.
|
||||||
|
</p>
|
||||||
|
</Info>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
const v = window.prompt('Enter debug value', debugValue)
|
||||||
|
|
||||||
|
await setDebugValue(v)
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Set value
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
clearValue()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear value
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
window.alert(debugValue)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Show value
|
||||||
|
</Button>
|
||||||
|
{enabled && !connected && (
|
||||||
|
<div className='mt-2 d-flex align-items-center'>
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
variant='danger'
|
||||||
|
onClick={reset}
|
||||||
|
>
|
||||||
|
Reset device sync data
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Info>
|
||||||
|
<p>
|
||||||
|
If you have lost your passphrase or wish to erase all encrypted data from the server, you can reset the device sync data and start over.
|
||||||
|
</p>
|
||||||
|
<p className='text-muted text-sm'>
|
||||||
|
This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
</Info>
|
||||||
|
</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 ConnectForm ({ onClose, onConnect, enabled }) {
|
||||||
|
const [passphrase, setPassphrase] = useState(!enabled ? generatePassphrase : '')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const scannedPassphrase = window.localStorage.getItem('qr:passphrase')
|
||||||
|
if (scannedPassphrase) {
|
||||||
|
setPassphrase(scannedPassphrase)
|
||||||
|
window.localStorage.removeItem('qr:passphrase')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const newPassphrase = useCallback(() => {
|
||||||
|
setPassphrase(() => generatePassphrase(12))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>{!enabled ? 'Enable device sync' : 'Input your passphrase'}</h2>
|
||||||
|
<p>
|
||||||
|
{!enabled
|
||||||
|
? 'Enable secure sync of sensitive data (like wallet credentials) between your devices. You’ll need to enter this passphrase on each device you want to connect.'
|
||||||
|
: 'Enter the passphrase from device sync to access your encrypted sensitive data (like wallet credentials) on the server.'}
|
||||||
|
</p>
|
||||||
|
<Form
|
||||||
|
schema={enabled ? undefined : deviceSyncSchema}
|
||||||
|
initial={{ passphrase }}
|
||||||
|
enableReinitialize
|
||||||
|
onSubmit={async (values, formik) => {
|
||||||
|
try {
|
||||||
|
await onConnect(values, formik)
|
||||||
|
onClose()
|
||||||
|
} catch {}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PasswordInput
|
||||||
|
label='passphrase'
|
||||||
|
name='passphrase'
|
||||||
|
placeholder=''
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
as='textarea'
|
||||||
|
rows={3}
|
||||||
|
readOnly={!enabled}
|
||||||
|
copy={!enabled}
|
||||||
|
append={
|
||||||
|
!enabled && (
|
||||||
|
<InputGroup.Text style={{ cursor: 'pointer', userSelect: 'none' }} onClick={newPassphrase}>
|
||||||
|
<RefreshIcon width={16} height={16} />
|
||||||
|
</InputGroup.Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p className='text-muted text-sm'>
|
||||||
|
{
|
||||||
|
!enabled
|
||||||
|
? 'This passphrase is stored only on your device and cannot be shown again.'
|
||||||
|
: 'If you have forgotten your passphrase, you can reset and start over.'
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
<div className='mt-3'>
|
||||||
|
<div className='d-flex justify-content-between'>
|
||||||
|
<div className='d-flex align-items-center ms-auto gap-2'>
|
||||||
|
<CancelButton onClick={onClose} />
|
||||||
|
<SubmitButton variant='primary'>{enabled ? 'connect' : 'enable'}</SubmitButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
|
@ -33,6 +33,12 @@ import EyeClose from '@/svgs/eye-close-line.svg'
|
||||||
import Info from './info'
|
import Info from './info'
|
||||||
import { useMe } from './me'
|
import { useMe } from './me'
|
||||||
import classNames from 'classnames'
|
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 QRCode from 'qrcode.react'
|
||||||
|
import { QrScanner } from '@yudiel/react-qr-scanner'
|
||||||
|
|
||||||
export class SessionRequiredError extends Error {
|
export class SessionRequiredError extends Error {
|
||||||
constructor () {
|
constructor () {
|
||||||
|
@ -69,31 +75,41 @@ export function SubmitButton ({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CopyInput (props) {
|
function CopyButton ({ value, icon, ...props }) {
|
||||||
const toaster = useToast()
|
const toaster = useToast()
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
const handleClick = async () => {
|
const handleClick = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
await copy(props.placeholder)
|
await copy(value)
|
||||||
toaster.success('copied')
|
toaster.success('copied')
|
||||||
setCopied(true)
|
setCopied(true)
|
||||||
setTimeout(() => setCopied(false), 1500)
|
setTimeout(() => setCopied(false), 1500)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toaster.danger('failed to copy')
|
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 (
|
return (
|
||||||
<Input
|
<Input
|
||||||
onClick={handleClick}
|
|
||||||
append={
|
append={
|
||||||
<Button
|
<CopyButton value={props.placeholder} size={props.size} />
|
||||||
className={styles.appendButton}
|
|
||||||
size={props.size}
|
|
||||||
onClick={handleClick}
|
|
||||||
>{copied ? <Thumb width={18} height={18} /> : 'copy'}
|
|
||||||
</Button>
|
|
||||||
}
|
}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
@ -711,10 +727,11 @@ export function InputUserSuggest ({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Input ({ label, groupClassName, ...props }) {
|
export function Input ({ label, groupClassName, under, ...props }) {
|
||||||
return (
|
return (
|
||||||
<FormGroup label={label} className={groupClassName}>
|
<FormGroup label={label} className={groupClassName}>
|
||||||
<InputInner {...props} />
|
<InputInner {...props} />
|
||||||
|
{under}
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -1070,24 +1087,121 @@ function PasswordHider ({ onClick, showPass }) {
|
||||||
>
|
>
|
||||||
{!showPass
|
{!showPass
|
||||||
? <Eye
|
? <Eye
|
||||||
fill='var(--bs-body-color)' height={20} width={20}
|
fill='var(--bs-body-color)' height={16} width={16}
|
||||||
/>
|
/>
|
||||||
: <EyeClose
|
: <EyeClose
|
||||||
fill='var(--bs-body-color)' height={20} width={20}
|
fill='var(--bs-body-color)' height={16} width={16}
|
||||||
/>}
|
/>}
|
||||||
</InputGroup.Text>
|
</InputGroup.Text>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PasswordInput ({ newPass, ...props }) {
|
function QrPassword ({ value }) {
|
||||||
|
const showModal = useShowModal()
|
||||||
|
const toaster = useToast()
|
||||||
|
|
||||||
|
const showQr = useCallback(() => {
|
||||||
|
showModal(close => (
|
||||||
|
<div className={styles.qr}>
|
||||||
|
<p>You can import this passphrase into another device by scanning this QR code</p>
|
||||||
|
<QRCode value={value} renderAs='svg' />
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
}, [toaster, value, showModal])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<InputGroup.Text
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={showQr}
|
||||||
|
>
|
||||||
|
<QrIcon height={16} width={16} />
|
||||||
|
</InputGroup.Text>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PasswordScanner ({ onDecode }) {
|
||||||
|
const showModal = useShowModal()
|
||||||
|
const toaster = useToast()
|
||||||
|
const ref = useRef(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InputGroup.Text
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={() => {
|
||||||
|
showModal(onClose => {
|
||||||
|
return (
|
||||||
|
<QrScanner
|
||||||
|
onDecode={(decoded) => {
|
||||||
|
onDecode(decoded)
|
||||||
|
|
||||||
|
// avoid accidentally calling onClose multiple times
|
||||||
|
if (ref?.current) return
|
||||||
|
ref.current = true
|
||||||
|
|
||||||
|
onClose({ back: 1 })
|
||||||
|
}}
|
||||||
|
onError={(error) => {
|
||||||
|
if (error instanceof DOMException) return
|
||||||
|
toaster.danger('qr scan error:', error.message || error.toString?.())
|
||||||
|
onClose({ back: 1 })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<QrScanIcon
|
||||||
|
height={20} width={20} fill='var(--bs-body-color)'
|
||||||
|
/>
|
||||||
|
</InputGroup.Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PasswordInput ({ newPass, qr, copy, readOnly, append, ...props }) {
|
||||||
const [showPass, setShowPass] = useState(false)
|
const [showPass, setShowPass] = useState(false)
|
||||||
|
const [field] = 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
|
||||||
|
onDecode={decoded => {
|
||||||
|
// Formik helpers don't seem to work in another modal.
|
||||||
|
// I assume it's because we unmount the Formik component
|
||||||
|
// when replace it with another modal.
|
||||||
|
window.localStorage.setItem('qr:passphrase', decoded)
|
||||||
|
}}
|
||||||
|
/>)}
|
||||||
|
{append}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}, [showPass, copy, field?.value, qr, readOnly, append])
|
||||||
|
|
||||||
|
const maskedValue = !showPass && props.as === 'textarea' ? field?.value?.replace(/./g, '•') : field?.value
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ClientInput
|
<ClientInput
|
||||||
{...props}
|
{...props}
|
||||||
|
className={styles.passwordInput}
|
||||||
type={showPass ? 'text' : 'password'}
|
type={showPass ? 'text' : 'password'}
|
||||||
autoComplete={newPass ? 'new-password' : 'current-password'}
|
autoComplete={newPass ? 'new-password' : 'current-password'}
|
||||||
append={<PasswordHider showPass={showPass} onClick={() => setShowPass(!showPass)} />}
|
readOnly={readOnly}
|
||||||
|
append={props.as === 'textarea' ? undefined : Append}
|
||||||
|
value={maskedValue}
|
||||||
|
under={props.as === 'textarea'
|
||||||
|
? (
|
||||||
|
<div className='mt-2 d-flex justify-content-end' style={{ gap: '8px' }}>
|
||||||
|
{Append}
|
||||||
|
</div>)
|
||||||
|
: undefined}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,10 @@
|
||||||
border-top-left-radius: 0;
|
border-top-left-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
textarea.passwordInput {
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
|
||||||
.markdownInput textarea {
|
.markdownInput textarea {
|
||||||
margin-top: -1px;
|
margin-top: -1px;
|
||||||
font-size: 94%;
|
font-size: 94%;
|
||||||
|
@ -69,4 +73,16 @@
|
||||||
0% {
|
0% {
|
||||||
opacity: 42%;
|
opacity: 42%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
div.qr {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.qr>svg {
|
||||||
|
justify-self: center;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
|
@ -45,13 +45,20 @@ export default function useModal () {
|
||||||
}, [getCurrentContent, forceUpdate])
|
}, [getCurrentContent, forceUpdate])
|
||||||
|
|
||||||
// this is called on every navigation due to below useEffect
|
// 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) {
|
while (modalStack.current.length) {
|
||||||
getCurrentContent()?.options?.onClose?.()
|
getCurrentContent()?.options?.onClose?.()
|
||||||
modalStack.current.pop()
|
modalStack.current.pop()
|
||||||
}
|
}
|
||||||
forceUpdate()
|
forceUpdate()
|
||||||
}, [])
|
}, [onBack])
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -90,7 +97,7 @@ export default function useModal () {
|
||||||
{overflow}
|
{overflow}
|
||||||
</ActionDropdown>
|
</ActionDropdown>
|
||||||
</div>}
|
</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 className={'modal-btn modal-close ' + className} onClick={onClose}>X</div>
|
||||||
</div>
|
</div>
|
||||||
<Modal.Body className={className}>
|
<Modal.Body className={className}>
|
||||||
|
|
|
@ -25,6 +25,7 @@ import { useHasNewNotes } from '../use-has-new-notes'
|
||||||
import { useWallets } from 'wallets'
|
import { useWallets } from 'wallets'
|
||||||
import SwitchAccountList, { useAccounts } from '@/components/account'
|
import SwitchAccountList, { useAccounts } from '@/components/account'
|
||||||
import { useShowModal } from '@/components/modal'
|
import { useShowModal } from '@/components/modal'
|
||||||
|
import { unsetLocalKey as resetVaultKey } from '@/components/use-vault'
|
||||||
|
|
||||||
export function Brand ({ className }) {
|
export function Brand ({ className }) {
|
||||||
return (
|
return (
|
||||||
|
@ -265,6 +266,7 @@ function LogoutObstacle ({ onClose }) {
|
||||||
const { registration: swRegistration, togglePushSubscription } = useServiceWorker()
|
const { registration: swRegistration, togglePushSubscription } = useServiceWorker()
|
||||||
const wallets = useWallets()
|
const wallets = useWallets()
|
||||||
const { multiAuthSignout } = useAccounts()
|
const { multiAuthSignout } = useAccounts()
|
||||||
|
const { me } = useMe()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='d-flex m-auto flex-column w-fit-content'>
|
<div className='d-flex m-auto flex-column w-fit-content'>
|
||||||
|
@ -293,6 +295,7 @@ function LogoutObstacle ({ onClose }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
await wallets.resetClient().catch(console.error)
|
await wallets.resetClient().catch(console.error)
|
||||||
|
await resetVaultKey(me?.id)
|
||||||
|
|
||||||
await signOut({ callbackUrl: '/' })
|
await signOut({ callbackUrl: '/' })
|
||||||
}}
|
}}
|
||||||
|
|
|
@ -0,0 +1,291 @@
|
||||||
|
import { SSR } from '@/lib/constants'
|
||||||
|
import { useMe } from './me'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import createTaskQueue from '@/lib/task-queue'
|
||||||
|
|
||||||
|
const VERSION = 1
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A react hook to use the local storage
|
||||||
|
* It handles the lifecycle of the storage, opening and closing it as needed.
|
||||||
|
*
|
||||||
|
* @param {*} options
|
||||||
|
* @param {string} options.database - the database name
|
||||||
|
* @param {[string]} options.namespace - the namespace of the storage
|
||||||
|
* @returns {[object]} - the local storage
|
||||||
|
*/
|
||||||
|
export default function useLocalStorage ({ database = 'default', namespace = ['default'] }) {
|
||||||
|
const { me } = useMe()
|
||||||
|
if (!Array.isArray(namespace)) namespace = [namespace]
|
||||||
|
const joinedNamespace = namespace.join(':')
|
||||||
|
const [storage, setStorage] = useState(openLocalStorage({ database, userId: me?.id, namespace }))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const currentStorage = storage
|
||||||
|
const newStorage = openLocalStorage({ database, userId: me?.id, namespace })
|
||||||
|
setStorage(newStorage)
|
||||||
|
if (currentStorage) currentStorage.close()
|
||||||
|
return () => {
|
||||||
|
newStorage.close()
|
||||||
|
}
|
||||||
|
}, [me, database, joinedNamespace])
|
||||||
|
|
||||||
|
return [storage]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a local storage.
|
||||||
|
* This is an abstraction on top of IndexedDB or, when not available, an in-memory storage.
|
||||||
|
* A combination of userId, database and namespace is used to efficiently separate different storage units.
|
||||||
|
* Namespaces can be an array of strings, that will be internally joined to form a single namespace.
|
||||||
|
*
|
||||||
|
* @param {*} options
|
||||||
|
* @param {string} options.userId - the user that owns the storage (anon if not provided)
|
||||||
|
* @param {string} options.database - the database name (default if not provided)
|
||||||
|
* @param {[string]} options.namespace - the namespace of the storage (default if not provided)
|
||||||
|
* @returns {object} - the local storage
|
||||||
|
* @throws Error if the namespace is invalid
|
||||||
|
*/
|
||||||
|
export function openLocalStorage ({ userId, database = 'default', namespace = ['default'] }) {
|
||||||
|
if (!userId) userId = 'anon'
|
||||||
|
if (!Array.isArray(namespace)) namespace = [namespace]
|
||||||
|
if (SSR) return createMemBackend(userId, namespace)
|
||||||
|
|
||||||
|
let backend = newIdxDBBackend(userId, database, namespace)
|
||||||
|
|
||||||
|
if (!backend) {
|
||||||
|
console.warn('no local storage backend available, fallback to in memory storage')
|
||||||
|
backend = createMemBackend(userId, namespace)
|
||||||
|
}
|
||||||
|
return backend
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listLocalStorages ({ userId, database }) {
|
||||||
|
if (SSR) return []
|
||||||
|
return await listIdxDBBackendNamespaces(userId, database)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In memory storage backend (volatile/dummy storage)
|
||||||
|
*/
|
||||||
|
function createMemBackend (userId, namespace) {
|
||||||
|
const joinedNamespace = userId + ':' + namespace.join(':')
|
||||||
|
let memory = window?.snMemStorage?.[joinedNamespace]
|
||||||
|
if (!memory) {
|
||||||
|
memory = {}
|
||||||
|
if (window) {
|
||||||
|
if (!window.snMemStorage) window.snMemStorage = {}
|
||||||
|
window.snMemStorage[joinedNamespace] = memory
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
set: (key, value) => { memory[key] = value },
|
||||||
|
get: (key) => memory[key],
|
||||||
|
unset: (key) => { delete memory[key] },
|
||||||
|
clear: () => { Object.keys(memory).forEach(key => delete memory[key]) },
|
||||||
|
list: () => Object.keys(memory),
|
||||||
|
close: () => { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open an IndexedDB connection
|
||||||
|
* @param {*} userId
|
||||||
|
* @param {*} database
|
||||||
|
* @param {*} onupgradeneeded
|
||||||
|
* @param {*} queue
|
||||||
|
* @returns {object} - an open connection
|
||||||
|
* @throws Error if the connection cannot be opened
|
||||||
|
*/
|
||||||
|
async function openIdxDB (userId, database, onupgradeneeded, queue) {
|
||||||
|
const fullDbName = `${database}:${userId}`
|
||||||
|
// we keep a reference to every open indexed db connection
|
||||||
|
// to reuse them whenever possible
|
||||||
|
if (window && !window.snIdxDB) window.snIdxDB = {}
|
||||||
|
let openConnection = window?.snIdxDB?.[fullDbName]
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
const conn = openConnection
|
||||||
|
conn.ref--
|
||||||
|
if (conn.ref === 0) { // close the connection for real if nothing is using it
|
||||||
|
if (window?.snIdxDB) delete window.snIdxDB[fullDbName]
|
||||||
|
queue.enqueue(() => {
|
||||||
|
conn.db.close()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// if for any reason the connection is outdated, we close it
|
||||||
|
if (openConnection && openConnection.version !== VERSION) {
|
||||||
|
close()
|
||||||
|
openConnection = undefined
|
||||||
|
}
|
||||||
|
// an open connections is not available, so we create a new one
|
||||||
|
if (!openConnection) {
|
||||||
|
openConnection = {
|
||||||
|
version: VERSION,
|
||||||
|
ref: 1, // we need a ref count to know when to close the connection for real
|
||||||
|
db: null,
|
||||||
|
close
|
||||||
|
}
|
||||||
|
openConnection.db = await new Promise((resolve, reject) => {
|
||||||
|
const request = window.indexedDB.open(fullDbName, VERSION)
|
||||||
|
request.onupgradeneeded = (event) => {
|
||||||
|
const db = event.target.result
|
||||||
|
if (onupgradeneeded) onupgradeneeded(db)
|
||||||
|
}
|
||||||
|
request.onsuccess = (event) => {
|
||||||
|
const db = event.target.result
|
||||||
|
if (!db?.transaction) reject(new Error('unsupported implementation'))
|
||||||
|
else resolve(db)
|
||||||
|
}
|
||||||
|
request.onerror = reject
|
||||||
|
})
|
||||||
|
window.snIdxDB[fullDbName] = openConnection
|
||||||
|
} else {
|
||||||
|
// increase the reference count
|
||||||
|
openConnection.ref++
|
||||||
|
}
|
||||||
|
return openConnection
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An IndexedDB based persistent storage
|
||||||
|
* @param {string} userId - the user that owns the storage
|
||||||
|
* @param {string} database - the database name
|
||||||
|
* @returns {object} - an indexedDB persistent storage
|
||||||
|
* @throws Error if the namespace is invalid
|
||||||
|
*/
|
||||||
|
function newIdxDBBackend (userId, database, namespace) {
|
||||||
|
if (!window.indexedDB) return undefined
|
||||||
|
if (!namespace) throw new Error('missing namespace')
|
||||||
|
if (!Array.isArray(namespace) || !namespace.length || namespace.find(n => !n || typeof n !== 'string')) throw new Error('invalid namespace. must be a non-empty array of strings')
|
||||||
|
if (namespace.find(n => n.includes(':'))) throw new Error('invalid namespace. must not contain ":"')
|
||||||
|
|
||||||
|
namespace = namespace.join(':')
|
||||||
|
|
||||||
|
const queue = createTaskQueue()
|
||||||
|
|
||||||
|
let openConnection = null
|
||||||
|
|
||||||
|
const initialize = async () => {
|
||||||
|
if (!openConnection) {
|
||||||
|
openConnection = await openIdxDB(userId, database, (db) => {
|
||||||
|
db.createObjectStore(database, { keyPath: ['namespace', 'key'] })
|
||||||
|
}, queue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
set: async (key, value) => {
|
||||||
|
await queue.enqueue(async () => {
|
||||||
|
await initialize()
|
||||||
|
const tx = openConnection.db.transaction([database], 'readwrite')
|
||||||
|
const objectStore = tx.objectStore(database)
|
||||||
|
objectStore.put({ namespace, key, value })
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
tx.oncomplete = resolve
|
||||||
|
tx.onerror = reject
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
get: async (key) => {
|
||||||
|
return await queue.enqueue(async () => {
|
||||||
|
await initialize()
|
||||||
|
const tx = openConnection.db.transaction([database], 'readonly')
|
||||||
|
const objectStore = tx.objectStore(database)
|
||||||
|
const request = objectStore.get([namespace, key])
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
request.onsuccess = () => resolve(request.result?.value)
|
||||||
|
request.onerror = reject
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
unset: async (key) => {
|
||||||
|
await queue.enqueue(async () => {
|
||||||
|
await initialize()
|
||||||
|
const tx = openConnection.db.transaction([database], 'readwrite')
|
||||||
|
const objectStore = tx.objectStore(database)
|
||||||
|
objectStore.delete([namespace, key])
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
tx.oncomplete = resolve
|
||||||
|
tx.onerror = reject
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
clear: async () => {
|
||||||
|
await queue.enqueue(async () => {
|
||||||
|
await initialize()
|
||||||
|
const tx = openConnection.db.transaction([database], 'readwrite')
|
||||||
|
const objectStore = tx.objectStore(database)
|
||||||
|
objectStore.clear()
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
tx.oncomplete = resolve
|
||||||
|
tx.onerror = reject
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
list: async () => {
|
||||||
|
return await queue.enqueue(async () => {
|
||||||
|
await initialize()
|
||||||
|
const tx = openConnection.db.transaction([database], 'readonly')
|
||||||
|
const objectStore = tx.objectStore(database)
|
||||||
|
const keys = []
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const request = objectStore.openCursor()
|
||||||
|
request.onsuccess = (event) => {
|
||||||
|
const cursor = event.target.result
|
||||||
|
if (cursor) {
|
||||||
|
if (cursor.key[0] === namespace) {
|
||||||
|
keys.push(cursor.key[1]) // Push only the 'key' part of the composite key
|
||||||
|
}
|
||||||
|
cursor.continue()
|
||||||
|
} else {
|
||||||
|
resolve(keys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
request.onerror = reject
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
close: async () => {
|
||||||
|
queue.enqueue(async () => {
|
||||||
|
if (openConnection) await openConnection.close()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all the namespaces used in an IndexedDB database
|
||||||
|
* @param {*} userId - the user that owns the storage
|
||||||
|
* @param {*} database - the database name
|
||||||
|
* @returns {array} - an array of namespace names
|
||||||
|
*/
|
||||||
|
async function listIdxDBBackendNamespaces (userId, database) {
|
||||||
|
if (!window?.indexedDB) return []
|
||||||
|
const queue = createTaskQueue()
|
||||||
|
const openConnection = await openIdxDB(userId, database, null, queue)
|
||||||
|
try {
|
||||||
|
const list = await queue.enqueue(async () => {
|
||||||
|
const objectStore = openConnection.db.transaction([database], 'readonly').objectStore(database)
|
||||||
|
const namespaces = new Set()
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const request = objectStore.openCursor()
|
||||||
|
request.onsuccess = (event) => {
|
||||||
|
const cursor = event.target.result
|
||||||
|
if (cursor) {
|
||||||
|
namespaces.add(cursor.key[0])
|
||||||
|
cursor.continue()
|
||||||
|
} else {
|
||||||
|
resolve(Array.from(namespaces).map(n => n.split(':')))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
request.onerror = reject
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return list
|
||||||
|
} finally {
|
||||||
|
openConnection.close()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,426 @@
|
||||||
|
import { useCallback, useState, useEffect, useRef } from 'react'
|
||||||
|
import { useMe } from '@/components/me'
|
||||||
|
import { useMutation, useApolloClient } from '@apollo/client'
|
||||||
|
import { SET_ENTRY, UNSET_ENTRY, GET_ENTRY, CLEAR_VAULT, SET_VAULT_KEY_HASH } from '@/fragments/vault'
|
||||||
|
import { E_VAULT_KEY_EXISTS } from '@/lib/error'
|
||||||
|
import { useToast } from '@/components/toast'
|
||||||
|
import useLocalStorage, { openLocalStorage, listLocalStorages } from '@/components/use-local-storage'
|
||||||
|
import { toHex, fromHex } from '@/lib/hex'
|
||||||
|
import createTaskQueue from '@/lib/task-queue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A react hook to configure the vault for the current user
|
||||||
|
*/
|
||||||
|
export function useVaultConfigurator () {
|
||||||
|
const { me } = useMe()
|
||||||
|
const toaster = useToast()
|
||||||
|
const [setVaultKeyHash] = useMutation(SET_VAULT_KEY_HASH)
|
||||||
|
|
||||||
|
const [vaultKey, innerSetVaultKey] = useState(null)
|
||||||
|
const [config, configError] = useConfig()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!me) return
|
||||||
|
if (configError) {
|
||||||
|
toaster.danger('error loading vault configuration ' + configError.message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
(async () => {
|
||||||
|
let localVaultKey = await config.get('key')
|
||||||
|
if (localVaultKey && (!me.privates.vaultKeyHash || localVaultKey?.hash !== me.privates.vaultKeyHash)) {
|
||||||
|
// 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, me.privates.vaultKeyHash)
|
||||||
|
localVaultKey = null
|
||||||
|
await config.unset('key')
|
||||||
|
}
|
||||||
|
innerSetVaultKey(localVaultKey)
|
||||||
|
})()
|
||||||
|
}, [me?.privates?.vaultKeyHash, config, configError])
|
||||||
|
|
||||||
|
// clear vault: remove everything and reset the key
|
||||||
|
const [clearVault] = useMutation(CLEAR_VAULT, {
|
||||||
|
onCompleted: async () => {
|
||||||
|
await config.unset('key')
|
||||||
|
innerSetVaultKey(null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// initialize the vault and set a vault key
|
||||||
|
const setVaultKey = useCallback(async (passphrase) => {
|
||||||
|
const vaultKey = await deriveKey(me.id, passphrase)
|
||||||
|
await setVaultKeyHash({
|
||||||
|
variables: { 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
innerSetVaultKey(vaultKey)
|
||||||
|
await config.set('key', vaultKey)
|
||||||
|
}, [setVaultKeyHash])
|
||||||
|
|
||||||
|
// disconnect the user from the vault (will not clear or reset the passphrase, use clearVault for that)
|
||||||
|
const disconnectVault = useCallback(async () => {
|
||||||
|
await config.unset('key')
|
||||||
|
innerSetVaultKey(null)
|
||||||
|
}, [innerSetVaultKey, config])
|
||||||
|
|
||||||
|
return [vaultKey, setVaultKey, clearVault, disconnectVault]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A react hook to migrate local vault storage to the synched vault
|
||||||
|
*/
|
||||||
|
export function useVaultMigration () {
|
||||||
|
const { me } = useMe()
|
||||||
|
const apollo = useApolloClient()
|
||||||
|
// migrate local storage to vault
|
||||||
|
const migrate = useCallback(async () => {
|
||||||
|
let migratedCount = 0
|
||||||
|
const config = await openConfig(me?.id)
|
||||||
|
const vaultKey = await config.get('key')
|
||||||
|
if (!vaultKey) throw new Error('vault key not found')
|
||||||
|
// we collect all the storages used by the vault
|
||||||
|
const namespaces = await listLocalStorages({ userId: me?.id, database: 'vault', supportLegacy: true })
|
||||||
|
for (const namespace of namespaces) {
|
||||||
|
// we open every one of them and copy the entries to the vault
|
||||||
|
const storage = await openLocalStorage({ userId: me?.id, database: 'vault', namespace, supportLegacy: true })
|
||||||
|
const entryNames = await storage.list()
|
||||||
|
for (const entryName of entryNames) {
|
||||||
|
try {
|
||||||
|
const value = await storage.get(entryName)
|
||||||
|
if (!value) throw new Error('no value found in local storage')
|
||||||
|
// (we know the layout we use for vault entries)
|
||||||
|
const type = namespace[0]
|
||||||
|
const id = namespace[1]
|
||||||
|
if (!type || !id || isNaN(id)) throw new Error('unknown vault namespace layout')
|
||||||
|
// encrypt and store on the server
|
||||||
|
const encrypted = await encryptData(vaultKey.key, value)
|
||||||
|
const { data } = await apollo.mutate({
|
||||||
|
mutation: SET_ENTRY,
|
||||||
|
variables: {
|
||||||
|
key: entryName,
|
||||||
|
value: encrypted,
|
||||||
|
skipIfSet: true,
|
||||||
|
ownerType: type,
|
||||||
|
ownerId: Number(id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (data?.setVaultEntry) {
|
||||||
|
// clear local storage
|
||||||
|
await storage.unset(entryName)
|
||||||
|
migratedCount++
|
||||||
|
console.log('migrated to vault:', entryName)
|
||||||
|
} else {
|
||||||
|
throw new Error('could not set vault entry')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('failed migrate to vault:', entryName, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await storage.close()
|
||||||
|
}
|
||||||
|
return migratedCount
|
||||||
|
}, [me?.id])
|
||||||
|
|
||||||
|
return migrate
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A react hook to use the vault for a specific owner entity and key
|
||||||
|
* It will automatically handle the vault lifecycle and value updates
|
||||||
|
* @param {*} owner - the owner entity with id and type or __typename (must extend VaultOwner in the graphql schema)
|
||||||
|
* @param {*} key - the key to store and retrieve the value
|
||||||
|
* @param {*} defaultValue - the default value to return when no value is found
|
||||||
|
*
|
||||||
|
* @returns {Array} - An array containing:
|
||||||
|
* @returns {any} 0 - The current value stored in the vault.
|
||||||
|
* @returns {function(any): Promise<void>} 1 - A function to set a new value in the vault.
|
||||||
|
* @returns {function({onlyFromLocalStorage?: boolean}): Promise<void>} 2 - A function to clear the value in the vault.
|
||||||
|
* @returns {function(): Promise<void>} 3 - A function to refresh the value from the vault.
|
||||||
|
*/
|
||||||
|
export default function useVault (owner, key, defaultValue) {
|
||||||
|
const { me } = useMe()
|
||||||
|
const toaster = useToast()
|
||||||
|
const apollo = useApolloClient()
|
||||||
|
|
||||||
|
const [value, innerSetValue] = useState(undefined)
|
||||||
|
const vault = useRef(openVault(apollo, me, owner))
|
||||||
|
|
||||||
|
const setValue = useCallback(async (newValue) => {
|
||||||
|
innerSetValue(newValue)
|
||||||
|
return vault.current.set(key, newValue)
|
||||||
|
}, [key])
|
||||||
|
|
||||||
|
const clearValue = useCallback(async ({ onlyFromLocalStorage = false } = {}) => {
|
||||||
|
innerSetValue(defaultValue)
|
||||||
|
return vault.current.clear(key, { onlyFromLocalStorage })
|
||||||
|
}, [key, defaultValue])
|
||||||
|
|
||||||
|
const refreshData = useCallback(async () => {
|
||||||
|
innerSetValue(await vault.current.get(key))
|
||||||
|
}, [key])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const currentVault = vault.current
|
||||||
|
const newVault = openVault(apollo, me, owner)
|
||||||
|
vault.current = newVault
|
||||||
|
if (currentVault)currentVault.close()
|
||||||
|
refreshData().catch(e => toaster.danger('failed to refresh vault data: ' + e.message))
|
||||||
|
return () => {
|
||||||
|
newVault.close()
|
||||||
|
}
|
||||||
|
}, [me, owner, key])
|
||||||
|
|
||||||
|
return [value, setValue, clearValue, refreshData]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the vault for the given user and owner entry
|
||||||
|
* @param {*} apollo - the apollo client
|
||||||
|
* @param {*} user - the user entry with id and privates.vaultKeyHash
|
||||||
|
* @param {*} owner - the owner entry with id and type or __typename (must extend VaultOwner in the graphql schema)
|
||||||
|
*
|
||||||
|
* @returns {Object} - An object containing:
|
||||||
|
* @returns {function(string, any): Promise<any>} get - A function to get a value from the vault.
|
||||||
|
* @returns {function(string, any): Promise<void>} set - A function to set a new value in the vault.
|
||||||
|
* @returns {function(string, {onlyFromLocalStorage?: boolean}): Promise<void>} clear - A function to clear a value in the vault.
|
||||||
|
* @returns {function(): Promise<void>} refresh - A function to refresh the value from the vault.
|
||||||
|
*/
|
||||||
|
export function openVault (apollo, user, owner) {
|
||||||
|
const userId = user?.id
|
||||||
|
const type = owner?.__typename || owner?.type
|
||||||
|
const id = owner?.id
|
||||||
|
|
||||||
|
const localOnly = !userId
|
||||||
|
|
||||||
|
let config = null
|
||||||
|
let localStore = null
|
||||||
|
const queue = createTaskQueue()
|
||||||
|
|
||||||
|
const waitInitialization = async () => {
|
||||||
|
if (!config) {
|
||||||
|
config = await openConfig(userId)
|
||||||
|
}
|
||||||
|
if (!localStore) {
|
||||||
|
localStore = type && id ? await openLocalStorage({ userId, database: localOnly ? 'local-vault' : 'vault', namespace: [type, id] }) : null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getValue = async (key, defaultValue) => {
|
||||||
|
return await queue.enqueue(async () => {
|
||||||
|
await waitInitialization()
|
||||||
|
if (!localStore) return undefined
|
||||||
|
|
||||||
|
if (localOnly) {
|
||||||
|
// local only: we fetch from local storage and return
|
||||||
|
return ((await localStore.get(key)) || defaultValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
const localVaultKey = await config.get('key')
|
||||||
|
if (!localVaultKey?.hash) {
|
||||||
|
// no vault key set: use local storage
|
||||||
|
return ((await localStore.get(key)) || defaultValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((!user.privates.vaultKeyHash && localVaultKey?.hash) || (localVaultKey?.hash !== user.privates.vaultKeyHash)) {
|
||||||
|
// no or different vault setup on server: use unencrypted local storage
|
||||||
|
// and clear local key if it exists
|
||||||
|
console.log('Vault key hash mismatch, clearing local key', localVaultKey, user.privates.vaultKeyHash)
|
||||||
|
await config.unset('key')
|
||||||
|
return ((await localStore.get(key)) || defaultValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
// if vault key hash is set on the server and matches our local key, we try to fetch from the vault
|
||||||
|
{
|
||||||
|
const { data: queriedData, error: queriedError } = await apollo.query({
|
||||||
|
query: GET_ENTRY,
|
||||||
|
variables: { key, ownerId: id, ownerType: type },
|
||||||
|
nextFetchPolicy: 'no-cache',
|
||||||
|
fetchPolicy: 'no-cache'
|
||||||
|
})
|
||||||
|
console.log(queriedData)
|
||||||
|
if (queriedError) throw queriedError
|
||||||
|
const encryptedVaultValue = queriedData?.getVaultEntry?.value
|
||||||
|
if (encryptedVaultValue) {
|
||||||
|
try {
|
||||||
|
const vaultValue = await decryptData(localVaultKey.key, encryptedVaultValue)
|
||||||
|
// console.log('decrypted value from vault:', storageKey, encrypted, decrypted)
|
||||||
|
// remove local storage value if it exists
|
||||||
|
await localStore.unset(key)
|
||||||
|
return vaultValue
|
||||||
|
} catch (e) {
|
||||||
|
console.error('cannot read vault data:', key, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fallback to local storage
|
||||||
|
return ((await localStore.get(key)) || defaultValue)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const setValue = async (key, newValue) => {
|
||||||
|
return await queue.enqueue(async () => {
|
||||||
|
await waitInitialization()
|
||||||
|
|
||||||
|
if (!localStore) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const vaultKey = await config.get('key')
|
||||||
|
|
||||||
|
const useVault = vaultKey && vaultKey.hash === user.privates.vaultKeyHash
|
||||||
|
|
||||||
|
if (useVault && !localOnly) {
|
||||||
|
const encryptedValue = await encryptData(vaultKey.key, newValue)
|
||||||
|
console.log('store encrypted value in vault:', key)
|
||||||
|
await apollo.mutate({
|
||||||
|
mutation: SET_ENTRY,
|
||||||
|
variables: { key, value: encryptedValue, ownerId: id, ownerType: type }
|
||||||
|
})
|
||||||
|
// clear local storage (we get rid of stored unencrypted data as soon as it can be stored on the vault)
|
||||||
|
await localStore.unset(key)
|
||||||
|
} else {
|
||||||
|
console.log('store value in local storage:', key)
|
||||||
|
// otherwise use local storage
|
||||||
|
await localStore.set(key, newValue)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearValue = async (key, { onlyFromLocalStorage } = {}) => {
|
||||||
|
return await queue.enqueue(async () => {
|
||||||
|
await waitInitialization()
|
||||||
|
if (!localStore) return
|
||||||
|
|
||||||
|
const vaultKey = await config.get('key')
|
||||||
|
const useVault = vaultKey && vaultKey.hash === user.privates.vaultKeyHash
|
||||||
|
|
||||||
|
if (!localOnly && useVault && !onlyFromLocalStorage) {
|
||||||
|
await apollo.mutate({
|
||||||
|
mutation: UNSET_ENTRY,
|
||||||
|
variables: { key, ownerId: id, ownerType: type }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// clear local storage
|
||||||
|
await localStore.unset(key)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const close = async () => {
|
||||||
|
return await queue.enqueue(async () => {
|
||||||
|
await config?.close()
|
||||||
|
await localStore?.close()
|
||||||
|
config = null
|
||||||
|
localStore = null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { get: getValue, set: setValue, clear: clearValue, close }
|
||||||
|
}
|
||||||
|
|
||||||
|
function useConfig () {
|
||||||
|
return useLocalStorage({ database: 'vault-config', namespace: ['settings'], supportLegacy: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openConfig (userId) {
|
||||||
|
return await openLocalStorage({ userId, database: 'vault-config', namespace: ['settings'] })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt data using AES-GCM
|
||||||
|
* @param {CryptoKey} sharedKey - the key to use for encryption
|
||||||
|
* @param {Object} data - the data to encrypt
|
||||||
|
* @returns {Promise<string>} a string representing the encrypted data, can be passed to decryptData to get the original data back
|
||||||
|
*/
|
||||||
|
async function encryptData (sharedKey, data) {
|
||||||
|
// 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
|
||||||
|
const iv = window.crypto.getRandomValues(new Uint8Array(12))
|
||||||
|
const encoded = new TextEncoder().encode(JSON.stringify(data))
|
||||||
|
const encrypted = await window.crypto.subtle.encrypt(
|
||||||
|
{
|
||||||
|
name: 'AES-GCM',
|
||||||
|
iv
|
||||||
|
},
|
||||||
|
sharedKey,
|
||||||
|
encoded
|
||||||
|
)
|
||||||
|
return JSON.stringify({
|
||||||
|
iv: toHex(iv.buffer),
|
||||||
|
data: toHex(encrypted)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt data using AES-GCM
|
||||||
|
* @param {CryptoKey} sharedKey - the key to use for decryption
|
||||||
|
* @param {string} encryptedData - the encrypted data as returned by encryptData
|
||||||
|
* @returns {Promise<Object>} the original unencrypted data
|
||||||
|
*/
|
||||||
|
async function decryptData (sharedKey, encryptedData) {
|
||||||
|
const { iv, data } = JSON.parse(encryptedData)
|
||||||
|
const decrypted = await window.crypto.subtle.decrypt(
|
||||||
|
{
|
||||||
|
name: 'AES-GCM',
|
||||||
|
iv: fromHex(iv)
|
||||||
|
},
|
||||||
|
sharedKey,
|
||||||
|
fromHex(data)
|
||||||
|
)
|
||||||
|
const decoded = new TextDecoder().decode(decrypted)
|
||||||
|
return JSON.parse(decoded)
|
||||||
|
}
|
|
@ -145,7 +145,7 @@ export function useWalletLogger (wallet, setLogs) {
|
||||||
|
|
||||||
const log = useCallback(level => message => {
|
const log = useCallback(level => message => {
|
||||||
if (!wallet) {
|
if (!wallet) {
|
||||||
console.error('cannot log: no wallet set')
|
// console.error('cannot log: no wallet set')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -3,12 +3,68 @@ import { COMMENTS, COMMENTS_ITEM_EXT_FIELDS } from './comments'
|
||||||
import { ITEM_FIELDS, ITEM_FULL_FIELDS } from './items'
|
import { ITEM_FIELDS, ITEM_FULL_FIELDS } from './items'
|
||||||
import { SUB_FULL_FIELDS } from './subs'
|
import { SUB_FULL_FIELDS } from './subs'
|
||||||
|
|
||||||
export const STREAK_FIELDS = gql`
|
export const ME = gql`
|
||||||
fragment StreakFields on User {
|
{
|
||||||
optional {
|
me {
|
||||||
streak
|
id
|
||||||
gunStreak
|
name
|
||||||
horseStreak
|
bioId
|
||||||
|
photoId
|
||||||
|
privates {
|
||||||
|
autoDropBolt11s
|
||||||
|
diagnostics
|
||||||
|
noReferralLinks
|
||||||
|
fiatCurrency
|
||||||
|
satsFilter
|
||||||
|
hideCowboyHat
|
||||||
|
hideFromTopUsers
|
||||||
|
hideGithub
|
||||||
|
hideNostr
|
||||||
|
hideTwitter
|
||||||
|
hideInvoiceDesc
|
||||||
|
hideIsContributor
|
||||||
|
hideWalletBalance
|
||||||
|
hideWelcomeBanner
|
||||||
|
imgproxyOnly
|
||||||
|
showImagesAndVideos
|
||||||
|
lastCheckedJobs
|
||||||
|
nostrCrossposting
|
||||||
|
noteAllDescendants
|
||||||
|
noteCowboyHat
|
||||||
|
noteDeposits
|
||||||
|
noteWithdrawals
|
||||||
|
noteEarning
|
||||||
|
noteForwardedSats
|
||||||
|
noteInvites
|
||||||
|
noteItemSats
|
||||||
|
noteJobIndicator
|
||||||
|
noteMentions
|
||||||
|
noteItemMentions
|
||||||
|
sats
|
||||||
|
tipDefault
|
||||||
|
tipRandom
|
||||||
|
tipRandomMin
|
||||||
|
tipRandomMax
|
||||||
|
tipPopover
|
||||||
|
turboTipping
|
||||||
|
zapUndos
|
||||||
|
upvotePopover
|
||||||
|
wildWestMode
|
||||||
|
withdrawMaxFeeDefault
|
||||||
|
lnAddr
|
||||||
|
autoWithdrawMaxFeePercent
|
||||||
|
autoWithdrawThreshold
|
||||||
|
disableFreebies
|
||||||
|
vaultKeyHash
|
||||||
|
}
|
||||||
|
optional {
|
||||||
|
isContributor
|
||||||
|
stacked
|
||||||
|
streak
|
||||||
|
githubId
|
||||||
|
nostrAuthPubkey
|
||||||
|
twitterId
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
@ -371,3 +427,9 @@ export const USER_STATS = gql`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}`
|
}`
|
||||||
|
|
||||||
|
export const SET_VAULT_KEY_HASH = gql`
|
||||||
|
mutation setVaultKeyHash($hash: String!) {
|
||||||
|
setVaultKeyHash(hash: $hash)
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
|
@ -0,0 +1,70 @@
|
||||||
|
import { gql } from '@apollo/client'
|
||||||
|
|
||||||
|
export const VAULT_FIELDS = gql`
|
||||||
|
fragment VaultFields on Vault {
|
||||||
|
id
|
||||||
|
key
|
||||||
|
value
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const GET_ENTRY = gql`
|
||||||
|
${VAULT_FIELDS}
|
||||||
|
query GetVaultEntry(
|
||||||
|
$ownerId: ID!,
|
||||||
|
$ownerType: String!,
|
||||||
|
$key: String!
|
||||||
|
) {
|
||||||
|
getVaultEntry(ownerId: $ownerId, ownerType: $ownerType, key: $key) {
|
||||||
|
...VaultFields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const GET_ENTRIES = gql`
|
||||||
|
${VAULT_FIELDS}
|
||||||
|
query GetVaultEntries(
|
||||||
|
$ownerId: ID!,
|
||||||
|
$ownerType: String!
|
||||||
|
) {
|
||||||
|
getVaultEntries(ownerId: $ownerId, ownerType: $ownerType) {
|
||||||
|
...VaultFields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const SET_ENTRY = gql`
|
||||||
|
mutation SetVaultEntry(
|
||||||
|
$ownerId: ID!,
|
||||||
|
$ownerType: String!,
|
||||||
|
$key: String!,
|
||||||
|
$value: String!,
|
||||||
|
$skipIfSet: Boolean
|
||||||
|
) {
|
||||||
|
setVaultEntry(ownerId: $ownerId, ownerType: $ownerType, key: $key, value: $value, skipIfSet: $skipIfSet)
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const UNSET_ENTRY = gql`
|
||||||
|
mutation UnsetVaultEntry(
|
||||||
|
$ownerId: ID!,
|
||||||
|
$ownerType: String!,
|
||||||
|
$key: String!
|
||||||
|
) {
|
||||||
|
unsetVaultEntry(ownerId: $ownerId, ownerType: $ownerType, key: $key)
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const CLEAR_VAULT = gql`
|
||||||
|
mutation ClearVault {
|
||||||
|
clearVault
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const SET_VAULT_KEY_HASH = gql`
|
||||||
|
mutation SetVaultKeyHash($hash: String!) {
|
||||||
|
setVaultKeyHash(hash: $hash)
|
||||||
|
}
|
||||||
|
`
|
|
@ -188,7 +188,19 @@ export const WALLET_BY_TYPE = gql`
|
||||||
|
|
||||||
export const WALLETS = gql`
|
export const WALLETS = gql`
|
||||||
query Wallets {
|
query Wallets {
|
||||||
wallets {
|
wallets{
|
||||||
|
id
|
||||||
|
priority
|
||||||
|
type,
|
||||||
|
canSend,
|
||||||
|
canReceive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const BEST_SEND_WALLETS = gql`
|
||||||
|
query SendWallets {
|
||||||
|
wallets (includeSenders: true, includeReceivers: false, onlyEnabled: true) {
|
||||||
id
|
id
|
||||||
priority
|
priority
|
||||||
type
|
type
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { GraphQLError } from 'graphql'
|
||||||
export const E_FORBIDDEN = 'E_FORBIDDEN'
|
export const E_FORBIDDEN = 'E_FORBIDDEN'
|
||||||
export const E_UNAUTHENTICATED = 'E_UNAUTHENTICATED'
|
export const E_UNAUTHENTICATED = 'E_UNAUTHENTICATED'
|
||||||
export const E_BAD_INPUT = 'E_BAD_INPUT'
|
export const E_BAD_INPUT = 'E_BAD_INPUT'
|
||||||
|
export const E_VAULT_KEY_EXISTS = 'E_VAULT_KEY_EXISTS'
|
||||||
|
|
||||||
export class GqlAuthorizationError extends GraphQLError {
|
export class GqlAuthorizationError extends GraphQLError {
|
||||||
constructor (message) {
|
constructor (message) {
|
||||||
|
@ -17,7 +18,7 @@ export class GqlAuthenticationError extends GraphQLError {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GqlInputError extends GraphQLError {
|
export class GqlInputError extends GraphQLError {
|
||||||
constructor (message) {
|
constructor (message, code) {
|
||||||
super(message, { extensions: { code: E_BAD_INPUT } })
|
super(message, { extensions: { code: code || E_BAD_INPUT } })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,20 @@
|
||||||
|
/**
|
||||||
|
* Convert a buffer to a hex string
|
||||||
|
* @param {*} buffer - the buffer to convert
|
||||||
|
* @returns {string} - the hex string
|
||||||
|
*/
|
||||||
|
export function toHex (buffer) {
|
||||||
|
const byteArray = new Uint8Array(buffer)
|
||||||
|
const hexString = Array.from(byteArray, byte => byte.toString(16).padStart(2, '0')).join('')
|
||||||
|
return hexString
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a hex string to a buffer
|
||||||
|
* @param {string} hex - the hex string to convert
|
||||||
|
* @returns {ArrayBuffer} - the buffer
|
||||||
|
*/
|
||||||
|
export function fromHex (hex) {
|
||||||
|
const byteArray = new Uint8Array(hex.match(/.{1,2}/g).map(byte => parseInt(byte, 16)))
|
||||||
|
return byteArray.buffer
|
||||||
|
}
|
|
@ -0,0 +1,54 @@
|
||||||
|
/**
|
||||||
|
* Create a queue to run tasks sequentially
|
||||||
|
* @returns {Object} - the queue
|
||||||
|
* @returns {function} enqueue - Function to add a task to the queue
|
||||||
|
* @returns {function} lock - Function to lock the queue
|
||||||
|
* @returns {function} wait - Function to wait for the queue to be empty
|
||||||
|
*/
|
||||||
|
export default function createTaskQueue () {
|
||||||
|
const queue = {
|
||||||
|
queue: Promise.resolve(),
|
||||||
|
/**
|
||||||
|
* Enqueue a task to be run sequentially
|
||||||
|
* @param {function} fn - The task function to be enqueued
|
||||||
|
* @returns {Promise} - A promise that resolves with the result of the task function
|
||||||
|
*/
|
||||||
|
enqueue (fn) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
queue.queue = queue.queue.then(async () => {
|
||||||
|
try {
|
||||||
|
resolve(await fn())
|
||||||
|
} catch (e) {
|
||||||
|
reject(e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Lock the queue so that it can't move forward until unlocked
|
||||||
|
* @param {boolean} [wait=true] - Whether to wait for the lock to be acquired
|
||||||
|
* @returns {Promise<function>} - A promise that resolves with the unlock function
|
||||||
|
*/
|
||||||
|
async lock (wait = true) {
|
||||||
|
let unlock
|
||||||
|
const lock = new Promise((resolve) => { unlock = resolve })
|
||||||
|
const locking = new Promise((resolve) => {
|
||||||
|
queue.queue = queue.queue.then(() => {
|
||||||
|
resolve()
|
||||||
|
return lock
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if (wait) await locking
|
||||||
|
return unlock
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Wait for the queue to be empty
|
||||||
|
* @returns {Promise} - A promise that resolves when the queue is empty
|
||||||
|
*/
|
||||||
|
async wait () {
|
||||||
|
return queue.queue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return queue
|
||||||
|
}
|
|
@ -844,3 +844,23 @@ export const toNumber = (x, min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE
|
||||||
}
|
}
|
||||||
|
|
||||||
export const toPositiveNumber = (x) => toNumber(x, 0)
|
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
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
@ -6,6 +6,11 @@ export function fieldToGqlArg (field) {
|
||||||
return arg
|
return arg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// same as fieldToGqlArg, but makes the field always optional
|
||||||
|
export function fieldToGqlArgOptional (field) {
|
||||||
|
return `${field.name}: String`
|
||||||
|
}
|
||||||
|
|
||||||
export function generateResolverName (walletField) {
|
export function generateResolverName (walletField) {
|
||||||
const capitalized = walletField[0].toUpperCase() + walletField.slice(1)
|
const capitalized = walletField[0].toUpperCase() + walletField.slice(1)
|
||||||
return `upsert${capitalized}`
|
return `upsert${capitalized}`
|
||||||
|
@ -15,3 +20,43 @@ export function generateTypeDefName (walletType) {
|
||||||
const PascalCase = walletType.split('_').map(s => s[0].toUpperCase() + s.slice(1).toLowerCase()).join('')
|
const PascalCase = walletType.split('_').map(s => s[0].toUpperCase() + s.slice(1).toLowerCase()).join('')
|
||||||
return `Wallet${PascalCase}`
|
return `Wallet${PascalCase}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isServerField (f) {
|
||||||
|
return f.serverOnly || !f.clientOnly
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isClientField (f) {
|
||||||
|
return f.clientOnly || !f.serverOnly
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a wallet is configured based on its fields and config
|
||||||
|
* @param {*} param0
|
||||||
|
* @param {*} param0.fields - the fields of the wallet
|
||||||
|
* @param {*} param0.config - the configuration of the wallet
|
||||||
|
* @param {*} param0.serverOnly - if true, only check server fields
|
||||||
|
* @param {*} param0.clientOnly - if true, only check client fields
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export function isConfigured ({ fields, config, serverOnly = false, clientOnly = false }) {
|
||||||
|
if (!config || !fields) return false
|
||||||
|
|
||||||
|
fields = fields.filter(f => {
|
||||||
|
if (clientOnly) return isClientField(f)
|
||||||
|
if (serverOnly) return isServerField(f)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
|
@ -20,7 +20,7 @@ import { LoggerProvider } from '@/components/logger'
|
||||||
import { ChainFeeProvider } from '@/components/chain-fee.js'
|
import { ChainFeeProvider } from '@/components/chain-fee.js'
|
||||||
import dynamic from 'next/dynamic'
|
import dynamic from 'next/dynamic'
|
||||||
import { HasNewNotesProvider } from '@/components/use-has-new-notes'
|
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 { AccountProvider } from '@/components/account'
|
||||||
|
|
||||||
const PWAPrompt = dynamic(() => import('react-ios-pwa-prompt'), { ssr: false })
|
const PWAPrompt = dynamic(() => import('react-ios-pwa-prompt'), { ssr: false })
|
||||||
|
|
|
@ -31,6 +31,7 @@ import { OverlayTrigger, Tooltip } from 'react-bootstrap'
|
||||||
import { useField } from 'formik'
|
import { useField } from 'formik'
|
||||||
import styles from './settings.module.css'
|
import styles from './settings.module.css'
|
||||||
import { AuthBanner } from '@/components/banners'
|
import { AuthBanner } from '@/components/banners'
|
||||||
|
import DeviceSync from '@/components/device-sync'
|
||||||
|
|
||||||
export const getServerSideProps = getGetServerSideProps({ query: SETTINGS, authRequired: true })
|
export const getServerSideProps = getGetServerSideProps({ query: SETTINGS, authRequired: true })
|
||||||
|
|
||||||
|
@ -606,6 +607,7 @@ export default function Settings ({ ssrData }) {
|
||||||
<div className='form-label'>saturday newsletter</div>
|
<div className='form-label'>saturday newsletter</div>
|
||||||
<Button href='https://mail.stacker.news/subscription/form' target='_blank'>(re)subscribe</Button>
|
<Button href='https://mail.stacker.news/subscription/form' target='_blank'>(re)subscribe</Button>
|
||||||
{settings?.authMethods && <AuthMethods methods={settings.authMethods} apiKeyEnabled={settings.apiKeyEnabled} />}
|
{settings?.authMethods && <AuthMethods methods={settings.authMethods} apiKeyEnabled={settings.apiKeyEnabled} />}
|
||||||
|
<DeviceSync />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
|
@ -22,7 +22,7 @@ export default function WalletSettings () {
|
||||||
const { wallet: name } = router.query
|
const { wallet: name } = router.query
|
||||||
const wallet = useWallet(name)
|
const wallet = useWallet(name)
|
||||||
|
|
||||||
const initial = wallet.fields.reduce((acc, field) => {
|
const initial = wallet?.fields.reduce((acc, field) => {
|
||||||
// We still need to run over all wallet fields via reduce
|
// We still need to run over all wallet fields via reduce
|
||||||
// even though we use wallet.config as the initial value
|
// even though we use wallet.config as the initial value
|
||||||
// since wallet.config is empty when wallet is not configured.
|
// since wallet.config is empty when wallet is not configured.
|
||||||
|
@ -30,27 +30,27 @@ export default function WalletSettings () {
|
||||||
// 'enabled' and 'priority' which are not defined in wallet.fields.
|
// 'enabled' and 'priority' which are not defined in wallet.fields.
|
||||||
return {
|
return {
|
||||||
...acc,
|
...acc,
|
||||||
[field.name]: wallet.config?.[field.name] || ''
|
[field.name]: wallet?.config?.[field.name] || ''
|
||||||
}
|
}
|
||||||
}, wallet.config)
|
}, wallet?.config)
|
||||||
|
|
||||||
// check if wallet uses the form-level validation built into Formik or a Yup schema
|
// check if wallet uses the form-level validation built into Formik or a Yup schema
|
||||||
const validateProps = typeof wallet.fieldValidation === 'function'
|
const validateProps = typeof wallet?.fieldValidation === 'function'
|
||||||
? { validate: wallet.fieldValidation }
|
? { validate: wallet?.fieldValidation }
|
||||||
: { schema: wallet.fieldValidation }
|
: { schema: wallet?.fieldValidation }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CenterLayout>
|
<CenterLayout>
|
||||||
<h2 className='pb-2'>{wallet.card.title}</h2>
|
<h2 className='pb-2'>{wallet?.card?.title}</h2>
|
||||||
<h6 className='text-muted text-center pb-3'><Text>{wallet.card.subtitle}</Text></h6>
|
<h6 className='text-muted text-center pb-3'><Text>{wallet?.card?.subtitle}</Text></h6>
|
||||||
{wallet.canSend && wallet.hasConfig > 0 && <WalletSecurityBanner />}
|
{wallet?.canSend && wallet?.hasConfig > 0 && <WalletSecurityBanner />}
|
||||||
<Form
|
<Form
|
||||||
initial={initial}
|
initial={initial}
|
||||||
enableReinitialize
|
enableReinitialize
|
||||||
{...validateProps}
|
{...validateProps}
|
||||||
onSubmit={async ({ amount, ...values }) => {
|
onSubmit={async ({ amount, ...values }) => {
|
||||||
try {
|
try {
|
||||||
const newConfig = !wallet.isConfigured
|
const newConfig = !wallet?.isConfigured
|
||||||
|
|
||||||
// enable wallet if wallet was just configured
|
// enable wallet if wallet was just configured
|
||||||
if (newConfig) {
|
if (newConfig) {
|
||||||
|
@ -67,13 +67,13 @@ export default function WalletSettings () {
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<WalletFields wallet={wallet} />
|
{wallet && <WalletFields wallet={wallet} />}
|
||||||
{wallet.walletType
|
{wallet?.walletType
|
||||||
? <AutowithdrawSettings wallet={wallet} />
|
? <AutowithdrawSettings wallet={wallet} />
|
||||||
: (
|
: (
|
||||||
<CheckboxGroup name='enabled'>
|
<CheckboxGroup name='enabled'>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
disabled={!wallet.isConfigured}
|
disabled={!wallet?.isConfigured}
|
||||||
label='enabled'
|
label='enabled'
|
||||||
name='enabled'
|
name='enabled'
|
||||||
groupClassName='mb-0'
|
groupClassName='mb-0'
|
||||||
|
@ -83,7 +83,7 @@ export default function WalletSettings () {
|
||||||
<WalletButtonBar
|
<WalletButtonBar
|
||||||
wallet={wallet} onDelete={async () => {
|
wallet={wallet} onDelete={async () => {
|
||||||
try {
|
try {
|
||||||
await wallet.delete()
|
await wallet?.delete()
|
||||||
toaster.success('saved settings')
|
toaster.success('saved settings')
|
||||||
router.push('/settings/wallets')
|
router.push('/settings/wallets')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
@ -95,7 +95,7 @@ export default function WalletSettings () {
|
||||||
/>
|
/>
|
||||||
</Form>
|
</Form>
|
||||||
<div className='mt-3 w-100'>
|
<div className='mt-3 w-100'>
|
||||||
<WalletLogs wallet={wallet} embedded />
|
{wallet && <WalletLogs wallet={wallet} embedded />}
|
||||||
</div>
|
</div>
|
||||||
</CenterLayout>
|
</CenterLayout>
|
||||||
)
|
)
|
||||||
|
|
|
@ -92,7 +92,12 @@ export default function Wallet ({ ssrData }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={w.name}
|
key={w?.name}
|
||||||
|
draggable={draggable}
|
||||||
|
style={{ cursor: draggable ? 'move' : 'default' }}
|
||||||
|
onDragStart={draggable ? onDragStart(i) : undefined}
|
||||||
|
onTouchStart={draggable ? onTouchStart(i) : undefined}
|
||||||
|
onDragEnter={draggable ? onDragEnter(i) : undefined}
|
||||||
className={
|
className={
|
||||||
!draggable
|
!draggable
|
||||||
? ''
|
? ''
|
||||||
|
|
|
@ -0,0 +1,28 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "users" ADD COLUMN "vaultKeyHash" TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Vault" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"key" VARCHAR(64) NOT NULL,
|
||||||
|
"value" TEXT NOT NULL,
|
||||||
|
"userId" INTEGER NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"ownerId" INTEGER NOT NULL,
|
||||||
|
"ownerType" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Vault_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Vault.userId_index" ON "Vault"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Vault.ownerId_ownerType_index" ON "Vault"("ownerId", "ownerType");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Vault_userId_key_ownerId_ownerType_key" ON "Vault"("userId", "key", "ownerId", "ownerType");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Vault" ADD CONSTRAINT "Vault_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
@ -0,0 +1,63 @@
|
||||||
|
-- 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 'BLINK';
|
||||||
|
ALTER TYPE "WalletType" ADD VALUE 'LNC';
|
||||||
|
ALTER TYPE "WalletType" ADD VALUE 'WEBLN';
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Wallet" ADD COLUMN "canReceive" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN "canSend" BOOLEAN NOT NULL DEFAULT true;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "WalletWebLn" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"walletId" INTEGER NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "WalletWebLn_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "WalletLNC" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"walletId" INTEGER NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "WalletLNC_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "WalletBlink" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"walletId" INTEGER NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "WalletBlink_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "WalletWebLn_walletId_key" ON "WalletWebLn"("walletId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "WalletLNC_walletId_key" ON "WalletLNC"("walletId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "WalletBlink_walletId_key" ON "WalletBlink"("walletId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "WalletWebLn" ADD CONSTRAINT "WalletWebLn_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "Wallet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "WalletLNC" ADD CONSTRAINT "WalletLNC_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "Wallet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "WalletBlink" ADD CONSTRAINT "WalletBlink_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "Wallet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
@ -0,0 +1,25 @@
|
||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the `WalletBlink` table. If the table is not empty, all the data it contains will be lost.
|
||||||
|
- You are about to drop the `WalletLNC` table. If the table is not empty, all the data it contains will be lost.
|
||||||
|
- You are about to drop the `WalletWebLn` table. If the table is not empty, all the data it contains will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "WalletBlink" DROP CONSTRAINT "WalletBlink_walletId_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "WalletLNC" DROP CONSTRAINT "WalletLNC_walletId_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "WalletWebLn" DROP CONSTRAINT "WalletWebLn_walletId_fkey";
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE "WalletBlink";
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE "WalletLNC";
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE "WalletWebLn";
|
|
@ -137,6 +137,8 @@ model User {
|
||||||
ItemUserAgg ItemUserAgg[]
|
ItemUserAgg ItemUserAgg[]
|
||||||
oneDayReferrals OneDayReferral[] @relation("OneDayReferral_referrer")
|
oneDayReferrals OneDayReferral[] @relation("OneDayReferral_referrer")
|
||||||
oneDayReferrees OneDayReferral[] @relation("OneDayReferral_referrees")
|
oneDayReferrees OneDayReferral[] @relation("OneDayReferral_referrees")
|
||||||
|
vaultKeyHash String @default("")
|
||||||
|
vaultEntries Vault[] @relation("VaultEntries")
|
||||||
|
|
||||||
@@index([photoId])
|
@@index([photoId])
|
||||||
@@index([createdAt], map: "users.created_at_index")
|
@@index([createdAt], map: "users.created_at_index")
|
||||||
|
@ -179,6 +181,9 @@ enum WalletType {
|
||||||
LNBITS
|
LNBITS
|
||||||
NWC
|
NWC
|
||||||
PHOENIXD
|
PHOENIXD
|
||||||
|
BLINK
|
||||||
|
LNC
|
||||||
|
WEBLN
|
||||||
}
|
}
|
||||||
|
|
||||||
model Wallet {
|
model Wallet {
|
||||||
|
@ -190,6 +195,8 @@ model Wallet {
|
||||||
enabled Boolean @default(true)
|
enabled Boolean @default(true)
|
||||||
priority Int @default(0)
|
priority Int @default(0)
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
canReceive Boolean @default(false)
|
||||||
|
canSend Boolean @default(true)
|
||||||
|
|
||||||
// NOTE: this denormalized json field exists to make polymorphic joins efficient
|
// NOTE: this denormalized json field exists to make polymorphic joins efficient
|
||||||
// when reading wallets ... it is populated by a trigger when wallet descendants update
|
// when reading wallets ... it is populated by a trigger when wallet descendants update
|
||||||
|
@ -1113,6 +1120,22 @@ model Reminder {
|
||||||
@@index([userId, remindAt], map: "Reminder.userId_reminderAt_index")
|
@@index([userId, remindAt], map: "Reminder.userId_reminderAt_index")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Vault {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
key String @db.VarChar(64)
|
||||||
|
value String @db.Text
|
||||||
|
userId Int
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade, name: "VaultEntries")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||||
|
ownerId Int
|
||||||
|
ownerType String
|
||||||
|
|
||||||
|
@@unique([userId, key, ownerId, ownerType])
|
||||||
|
@@index([userId], map: "Vault.userId_index")
|
||||||
|
@@index([ownerId, ownerType], map: "Vault.ownerId_ownerType_index")
|
||||||
|
}
|
||||||
|
|
||||||
enum EarnType {
|
enum EarnType {
|
||||||
POST
|
POST
|
||||||
COMMENT
|
COMMENT
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M7 4V2H17V4H20.0066C20.5552 4 21 4.44495 21 4.9934V21.0066C21 21.5552 20.5551 22 20.0066 22H3.9934C3.44476 22 3 21.5551 3 21.0066V4.9934C3 4.44476 3.44495 4 3.9934 4H7ZM7 6H5V20H19V6H17V8H7V6ZM9 4V6H15V4H9Z"></path></svg>
|
After Width: | Height: | Size: 310 B |
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M16 17V16H13V13H16V15H18V17H17V19H15V21H13V18H15V17H16ZM21 21H17V19H19V17H21V21ZM3 3H11V11H3V3ZM5 5V9H9V5H5ZM13 3H21V11H13V3ZM15 5V9H19V5H15ZM3 13H11V21H3V13ZM5 15V19H9V15H5ZM18 13H21V15H18V13ZM6 6H8V8H6V6ZM6 16H8V18H6V16ZM16 6H18V8H16V6Z"></path></svg>
|
After Width: | Height: | Size: 342 B |
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M21 16V21H3V16H5V19H19V16H21ZM3 11H21V13H3V11ZM21 8H19V5H5V8H3V3H21V8Z"></path></svg>
|
After Width: | Height: | Size: 174 B |
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M5.46257 4.43262C7.21556 2.91688 9.5007 2 12 2C17.5228 2 22 6.47715 22 12C22 14.1361 21.3302 16.1158 20.1892 17.7406L17 12H20C20 7.58172 16.4183 4 12 4C9.84982 4 7.89777 4.84827 6.46023 6.22842L5.46257 4.43262ZM18.5374 19.5674C16.7844 21.0831 14.4993 22 12 22C6.47715 22 2 17.5228 2 12C2 9.86386 2.66979 7.88416 3.8108 6.25944L7 12H4C4 16.4183 7.58172 20 12 20C14.1502 20 16.1022 19.1517 17.5398 17.7716L18.5374 19.5674Z"></path></svg>
|
After Width: | Height: | Size: 524 B |
|
@ -57,6 +57,10 @@ This acts as an ID for this wallet on the client. It therefore must be unique ac
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
- `perDevice?: boolean`
|
||||||
|
|
||||||
|
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[]`
|
- `fields: WalletField[]`
|
||||||
|
|
||||||
Wallet fields define what this wallet requires for configuration and thus are used to construct the forms like the one you can see at [/settings/wallets/lnbits](https://stacker.news/settings/walletslnbits).
|
Wallet fields define what this wallet requires for configuration and thus are used to construct the forms like the one you can see at [/settings/wallets/lnbits](https://stacker.news/settings/walletslnbits).
|
||||||
|
|
|
@ -4,6 +4,10 @@ export const galoyBlinkUrl = 'https://api.blink.sv/graphql'
|
||||||
export const galoyBlinkDashboardUrl = 'https://dashboard.blink.sv/'
|
export const galoyBlinkDashboardUrl = 'https://dashboard.blink.sv/'
|
||||||
|
|
||||||
export const name = 'blink'
|
export const name = 'blink'
|
||||||
|
export const walletType = 'BLINK'
|
||||||
|
export const walletField = 'walletBlink'
|
||||||
|
export const fieldValidation = blinkSchema
|
||||||
|
export const clientOnly = true
|
||||||
|
|
||||||
export const fields = [
|
export const fields = [
|
||||||
{
|
{
|
||||||
|
@ -30,5 +34,3 @@ export const card = {
|
||||||
subtitle: 'use [Blink](https://blink.sv/) for payments',
|
subtitle: 'use [Blink](https://blink.sv/) for payments',
|
||||||
badges: ['send only']
|
badges: ['send only']
|
||||||
}
|
}
|
||||||
|
|
||||||
export const fieldValidation = blinkSchema
|
|
||||||
|
|
|
@ -1,6 +1,9 @@
|
||||||
import { CLNAutowithdrawSchema } from '@/lib/validate'
|
import { CLNAutowithdrawSchema } from '@/lib/validate'
|
||||||
|
|
||||||
export const name = 'cln'
|
export const name = 'cln'
|
||||||
|
export const walletType = 'CLN'
|
||||||
|
export const walletField = 'walletCLN'
|
||||||
|
export const fieldValidation = CLNAutowithdrawSchema
|
||||||
|
|
||||||
export const fields = [
|
export const fields = [
|
||||||
{
|
{
|
||||||
|
@ -38,9 +41,3 @@ export const card = {
|
||||||
subtitle: 'autowithdraw to your Core Lightning node via [CLNRest](https://docs.corelightning.org/docs/rest)',
|
subtitle: 'autowithdraw to your Core Lightning node via [CLNRest](https://docs.corelightning.org/docs/rest)',
|
||||||
badges: ['receive only']
|
badges: ['receive only']
|
||||||
}
|
}
|
||||||
|
|
||||||
export const fieldValidation = CLNAutowithdrawSchema
|
|
||||||
|
|
||||||
export const walletType = 'CLN'
|
|
||||||
|
|
||||||
export const walletField = 'walletCLN'
|
|
||||||
|
|
535
wallets/index.js
535
wallets/index.js
|
@ -1,17 +1,16 @@
|
||||||
import { useCallback } from 'react'
|
import { useCallback, useState, useEffect, useRef, useMemo } from 'react'
|
||||||
import { useMe } from '@/components/me'
|
import { useMe } from '@/components/me'
|
||||||
import useClientConfig from '@/components/use-local-state'
|
import { openVault } from '@/components/use-vault'
|
||||||
import { useWalletLogger } from '@/components/wallet-logger'
|
import { useWalletLogger } from '@/components/wallet-logger'
|
||||||
import { SSR } from '@/lib/constants'
|
|
||||||
import { bolt11Tags } from '@/lib/bolt11'
|
import { bolt11Tags } from '@/lib/bolt11'
|
||||||
|
|
||||||
import walletDefs from 'wallets/client'
|
import walletDefs from 'wallets/client'
|
||||||
import { gql, useApolloClient, useMutation, useQuery } from '@apollo/client'
|
import { gql, useApolloClient, useMutation, useQuery } from '@apollo/client'
|
||||||
import { REMOVE_WALLET, WALLET_BY_TYPE } from '@/fragments/wallet'
|
import { REMOVE_WALLET, WALLET_BY_TYPE, BEST_SEND_WALLETS } from '@/fragments/wallet'
|
||||||
import { autowithdrawInitial } from '@/components/autowithdraw-shared'
|
import { autowithdrawInitial } from '@/components/autowithdraw-shared'
|
||||||
import { useShowModal } from '@/components/modal'
|
import { useShowModal } from '@/components/modal'
|
||||||
import { useToast } from '../components/toast'
|
import { useToast } from '../components/toast'
|
||||||
import { generateResolverName } from '@/lib/wallet'
|
import { generateResolverName, isConfigured, isClientField, isServerField } from '@/lib/wallet'
|
||||||
import { walletValidate } from '@/lib/validate'
|
import { walletValidate } from '@/lib/validate'
|
||||||
|
|
||||||
export const Status = {
|
export const Status = {
|
||||||
|
@ -27,100 +26,125 @@ export function useWallet (name) {
|
||||||
const toaster = useToast()
|
const toaster = useToast()
|
||||||
const [disableFreebies] = useMutation(gql`mutation { disableFreebies }`)
|
const [disableFreebies] = useMutation(gql`mutation { disableFreebies }`)
|
||||||
|
|
||||||
const wallet = name ? getWalletByName(name) : getEnabledWallet(me)
|
const { data: bestSendWalletList } = useQuery(BEST_SEND_WALLETS)
|
||||||
const { logger, deleteLogs } = useWalletLogger(wallet)
|
|
||||||
|
|
||||||
const [config, saveConfig, clearConfig] = useConfig(wallet)
|
if (!name) {
|
||||||
const hasConfig = wallet?.fields.length > 0
|
// find best wallet in list
|
||||||
const _isConfigured = isConfigured({ ...wallet, config })
|
const bestWalletDef = bestSendWalletList?.wallets
|
||||||
|
// .filter(w => w.enabled && w.canSend)// filtered by the server
|
||||||
|
// .sort((a, b) => b.priority - a.priority) // already priority sorted by the server
|
||||||
|
.map(w => getWalletByType(w.type))
|
||||||
|
.filter(w => !w.isAvailable || w.isAvailable())[0]
|
||||||
|
name = bestWalletDef?.name
|
||||||
|
}
|
||||||
|
|
||||||
const enablePayments = useCallback(() => {
|
const walletDef = getWalletByName(name)
|
||||||
enableWallet(name, me)
|
|
||||||
logger.ok('payments enabled')
|
|
||||||
disableFreebies().catch(console.error)
|
|
||||||
}, [name, me, logger])
|
|
||||||
|
|
||||||
const disablePayments = useCallback(() => {
|
const { logger, deleteLogs } = useWalletLogger(walletDef)
|
||||||
disableWallet(name, me)
|
const [config, saveConfig, clearConfig] = useConfig(walletDef)
|
||||||
logger.info('payments disabled')
|
|
||||||
}, [name, me, logger])
|
|
||||||
|
|
||||||
const status = config?.enabled ? Status.Enabled : Status.Initialized
|
const status = config?.enabled ? Status.Enabled : Status.Initialized
|
||||||
const enabled = status === Status.Enabled
|
const enabled = status === Status.Enabled
|
||||||
const priority = config?.priority
|
const priority = config?.priority
|
||||||
|
const hasConfig = walletDef?.fields?.length > 0
|
||||||
|
|
||||||
|
const _isConfigured = useCallback(() => {
|
||||||
|
return isConfigured({ ...walletDef, config })
|
||||||
|
}, [walletDef, config])
|
||||||
|
|
||||||
|
const enablePayments = useCallback((updatedConfig) => {
|
||||||
|
saveConfig({ ...(updatedConfig || config), enabled: true }, { skipTests: true })
|
||||||
|
logger.ok('payments enabled')
|
||||||
|
disableFreebies().catch(console.error)
|
||||||
|
}, [config])
|
||||||
|
|
||||||
|
const disablePayments = useCallback((updatedConfig) => {
|
||||||
|
saveConfig({ ...(updatedConfig || config), enabled: false }, { skipTests: true })
|
||||||
|
logger.info('payments disabled')
|
||||||
|
}, [config])
|
||||||
|
|
||||||
const sendPayment = useCallback(async (bolt11) => {
|
const sendPayment = useCallback(async (bolt11) => {
|
||||||
const hash = bolt11Tags(bolt11).payment_hash
|
const hash = bolt11Tags(bolt11).payment_hash
|
||||||
logger.info('sending payment:', `payment_hash=${hash}`)
|
logger.info('sending payment:', `payment_hash=${hash}`)
|
||||||
try {
|
try {
|
||||||
const preimage = await wallet.sendPayment(bolt11, config, { me, logger, status, showModal })
|
const preimage = await walletDef.sendPayment(bolt11, config, { me, logger, status, showModal })
|
||||||
logger.ok('payment successful:', `payment_hash=${hash}`, `preimage=${preimage}`)
|
logger.ok('payment successful:', `payment_hash=${hash}`, `preimage=${preimage}`)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err.message || err.toString?.()
|
const message = err.message || err.toString?.()
|
||||||
logger.error('payment failed:', `payment_hash=${hash}`, message)
|
logger.error('payment failed:', `payment_hash=${hash}`, message)
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
}, [me, wallet, config, logger, status])
|
}, [me, walletDef, config, status])
|
||||||
|
|
||||||
const setPriority = useCallback(async (priority) => {
|
const setPriority = useCallback(async (priority) => {
|
||||||
if (_isConfigured && priority !== config.priority) {
|
if (_isConfigured() && priority !== config.priority) {
|
||||||
try {
|
try {
|
||||||
await saveConfig({ ...config, priority }, { logger, priorityOnly: true })
|
await saveConfig({ ...config, priority }, { logger, skipTests: true })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toaster.danger(`failed to change priority of ${wallet.name} wallet: ${err.message}`)
|
toaster.danger(`failed to change priority of ${walletDef.name} wallet: ${err.message}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [wallet, config, toaster])
|
}, [walletDef, config])
|
||||||
|
|
||||||
const save = useCallback(async (newConfig) => {
|
const save = useCallback(async (newConfig) => {
|
||||||
await saveConfig(newConfig, { logger })
|
await saveConfig(newConfig, { logger })
|
||||||
}, [saveConfig, me, logger])
|
}, [saveConfig, me])
|
||||||
|
|
||||||
// delete is a reserved keyword
|
// delete is a reserved keyword
|
||||||
const delete_ = useCallback(async (options) => {
|
const delete_ = useCallback(async (options) => {
|
||||||
try {
|
try {
|
||||||
|
logger.ok('wallet detached for payments')
|
||||||
await clearConfig({ logger, ...options })
|
await clearConfig({ logger, ...options })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err.message || err.toString?.()
|
const message = err.message || err.toString?.()
|
||||||
logger.error(message)
|
logger.error(message)
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
}, [clearConfig, logger, disablePayments])
|
}, [clearConfig])
|
||||||
|
|
||||||
const deleteLogs_ = useCallback(async (options) => {
|
const deleteLogs_ = useCallback(async (options) => {
|
||||||
// first argument is to override the wallet
|
// first argument is to override the wallet
|
||||||
return await deleteLogs(options)
|
return await deleteLogs(options)
|
||||||
}, [deleteLogs])
|
}, [deleteLogs])
|
||||||
|
|
||||||
if (!wallet) return null
|
const wallet = useMemo(() => {
|
||||||
|
if (!walletDef) return {}
|
||||||
|
const available = (!walletDef.isAvailable || walletDef.isAvailable())
|
||||||
|
const wallet = {
|
||||||
|
...walletDef
|
||||||
|
}
|
||||||
|
wallet.isConfigured = _isConfigured()
|
||||||
|
wallet.enablePayments = enablePayments
|
||||||
|
wallet.disablePayments = disablePayments
|
||||||
|
wallet.canSend = config.canSend && available
|
||||||
|
wallet.canReceive = config.canReceive
|
||||||
|
wallet.config = config
|
||||||
|
wallet.save = save
|
||||||
|
wallet.delete = delete_
|
||||||
|
wallet.deleteLogs = deleteLogs_
|
||||||
|
wallet.setPriority = setPriority
|
||||||
|
wallet.hasConfig = hasConfig
|
||||||
|
wallet.status = status
|
||||||
|
wallet.enabled = enabled && available
|
||||||
|
wallet.priority = priority
|
||||||
|
wallet.logger = logger
|
||||||
|
wallet.sendPayment = sendPayment
|
||||||
|
wallet.def = walletDef
|
||||||
|
logger.ok(walletDef.isConfigured ? 'payment details updated' : 'wallet attached for payments')
|
||||||
|
return wallet
|
||||||
|
}, [walletDef, config, status, enabled, priority, logger, enablePayments, disablePayments, save, delete_, deleteLogs_, setPriority, hasConfig])
|
||||||
|
|
||||||
// Assign everything to wallet object so every function that is passed this wallet object in this
|
useEffect(() => {
|
||||||
// `useWallet` hook has access to all others via the reference to it.
|
if (wallet.enabled && wallet.canSend) {
|
||||||
// Essentially, you can now use functions like `enablePayments` _inside_ of functions that are
|
disableFreebies().catch(console.error)
|
||||||
// called by `useWallet` even before enablePayments is defined and not only in functions
|
logger.ok('payments enabled')
|
||||||
// that use the return value of `useWallet`.
|
}
|
||||||
wallet.isConfigured = _isConfigured
|
}, [wallet])
|
||||||
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
|
return wallet
|
||||||
// as an imported function and thus can't be overwritten
|
|
||||||
return { ...wallet, sendPayment }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractConfig (fields, config, client) {
|
function extractConfig (fields, config, client, includeMeta = true) {
|
||||||
return Object.entries(config).reduce((acc, [key, value]) => {
|
return Object.entries(config).reduce((acc, [key, value]) => {
|
||||||
const field = fields.find(({ name }) => name === key)
|
const field = fields.find(({ name }) => name === key)
|
||||||
|
|
||||||
|
@ -129,7 +153,7 @@ function extractConfig (fields, config, client) {
|
||||||
if (client && key === 'id') return acc
|
if (client && key === 'id') return acc
|
||||||
|
|
||||||
// field might not exist because config.enabled doesn't map to a wallet field
|
// field might not exist because config.enabled doesn't map to a wallet field
|
||||||
if (!field || (client ? isClientField(field) : isServerField(field))) {
|
if ((!field && includeMeta) || (field && (client ? isClientField(field) : isServerField(field)))) {
|
||||||
return {
|
return {
|
||||||
...acc,
|
...acc,
|
||||||
[key]: value
|
[key]: value
|
||||||
|
@ -140,205 +164,217 @@ function extractConfig (fields, config, client) {
|
||||||
}, {})
|
}, {})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isServerField (f) {
|
|
||||||
return f.serverOnly || !f.clientOnly
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isClientField (f) {
|
|
||||||
return f.clientOnly || !f.serverOnly
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractClientConfig (fields, config) {
|
function extractClientConfig (fields, config) {
|
||||||
return extractConfig(fields, config, true)
|
return extractConfig(fields, config, true, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractServerConfig (fields, config) {
|
function extractServerConfig (fields, config) {
|
||||||
return extractConfig(fields, config, false)
|
return extractConfig(fields, config, false, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
function useConfig (wallet) {
|
function useConfig (walletDef) {
|
||||||
|
const client = useApolloClient()
|
||||||
const { me } = useMe()
|
const { me } = useMe()
|
||||||
|
const toaster = useToast()
|
||||||
|
const autowithdrawSettings = autowithdrawInitial({ me })
|
||||||
|
const clientVault = useRef(null)
|
||||||
|
|
||||||
const storageKey = getStorageKey(wallet?.name, me)
|
const [config, innerSetConfig] = useState({})
|
||||||
const [clientConfig, setClientConfig, clearClientConfig] = useClientConfig(storageKey, {})
|
const [currentWallet, innerSetCurrentWallet] = useState(null)
|
||||||
|
|
||||||
const [serverConfig, setServerConfig, clearServerConfig] = useServerConfig(wallet)
|
const canSend = !!walletDef?.sendPayment
|
||||||
|
const canReceive = !walletDef?.clientOnly
|
||||||
|
|
||||||
const hasClientConfig = !!wallet?.sendPayment
|
const refreshConfig = useCallback(async () => {
|
||||||
const hasServerConfig = !!wallet?.walletType
|
if (walletDef) {
|
||||||
|
let newConfig = {}
|
||||||
|
newConfig = {
|
||||||
|
...autowithdrawSettings
|
||||||
|
}
|
||||||
|
|
||||||
let config = {}
|
// fetch server config
|
||||||
if (hasClientConfig) config = clientConfig
|
const serverConfig = await client.query({
|
||||||
if (hasServerConfig) {
|
query: WALLET_BY_TYPE,
|
||||||
const { enabled, priority } = config || {}
|
variables: { type: walletDef.walletType },
|
||||||
config = {
|
fetchPolicy: 'no-cache'
|
||||||
...config,
|
})
|
||||||
...serverConfig
|
|
||||||
|
if (serverConfig?.data?.walletByType) {
|
||||||
|
newConfig = {
|
||||||
|
...newConfig,
|
||||||
|
id: serverConfig.data.walletByType.id,
|
||||||
|
priority: serverConfig.data.walletByType.priority,
|
||||||
|
enabled: serverConfig.data.walletByType.enabled
|
||||||
|
}
|
||||||
|
if (serverConfig.data.walletByType.wallet) {
|
||||||
|
newConfig = {
|
||||||
|
...newConfig,
|
||||||
|
...serverConfig.data.walletByType.wallet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetch client config
|
||||||
|
let clientConfig = {}
|
||||||
|
if (serverConfig?.data?.walletByType) {
|
||||||
|
if (clientVault.current) {
|
||||||
|
clientVault.current.close()
|
||||||
|
}
|
||||||
|
const newClientVault = openVault(client, me, serverConfig.data.walletByType)
|
||||||
|
clientVault.current = newClientVault
|
||||||
|
clientConfig = await newClientVault.get(walletDef.name, {})
|
||||||
|
if (clientConfig) {
|
||||||
|
for (const [key, value] of Object.entries(clientConfig)) {
|
||||||
|
if (newConfig[key] === undefined) {
|
||||||
|
newConfig[key] = value
|
||||||
|
} else {
|
||||||
|
console.warn('Client config key', key, 'already exists in server config')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newConfig.canSend == null) {
|
||||||
|
newConfig.canSend = canSend && isConfigured({ fields: walletDef.fields, config: newConfig, clientOnly: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newConfig.canReceive == null) {
|
||||||
|
newConfig.canReceive = canReceive && isConfigured({ fields: walletDef.fields, config: newConfig, serverOnly: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
// console.log('Client config', clientConfig)
|
||||||
|
// console.log('Server config', serverConfig)
|
||||||
|
// console.log('Merged config', newConfig)
|
||||||
|
|
||||||
|
// set merged config
|
||||||
|
innerSetConfig(newConfig)
|
||||||
|
|
||||||
|
// set wallet ref
|
||||||
|
innerSetCurrentWallet(serverConfig.data.walletByType)
|
||||||
}
|
}
|
||||||
// wallet is enabled if enabled is set in client or server config
|
}, [walletDef, me])
|
||||||
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 }) => {
|
useEffect(() => {
|
||||||
// NOTE:
|
refreshConfig()
|
||||||
// verifying the client/server configuration before saving it
|
}, [walletDef, me])
|
||||||
// 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
|
const saveConfig = useCallback(async (newConfig, { logger, skipTests }) => {
|
||||||
|
const priorityOnly = skipTests
|
||||||
|
const { autoWithdrawThreshold, autoWithdrawMaxFeePercent, priority, enabled } = newConfig
|
||||||
|
try {
|
||||||
|
// gather configs
|
||||||
|
|
||||||
|
let newClientConfig = extractClientConfig(walletDef.fields, newConfig)
|
||||||
try {
|
try {
|
||||||
const transformedConfig = await walletValidate(wallet, newClientConfig)
|
const transformedConfig = await walletValidate(walletDef, newClientConfig)
|
||||||
if (transformedConfig) {
|
if (transformedConfig) {
|
||||||
newClientConfig = Object.assign(newClientConfig, transformedConfig)
|
newClientConfig = Object.assign(newClientConfig, transformedConfig)
|
||||||
}
|
}
|
||||||
// these are stored on the server
|
} catch (e) {
|
||||||
delete newClientConfig.autoWithdrawMaxFeePercent
|
newClientConfig = {}
|
||||||
delete newClientConfig.autoWithdrawThreshold
|
|
||||||
delete newClientConfig.autoWithdrawMaxFeeTotal
|
|
||||||
} catch {
|
|
||||||
valid = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (valid) {
|
let newServerConfig = extractServerConfig(walletDef.fields, newConfig)
|
||||||
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 {
|
try {
|
||||||
const transformedConfig = await walletValidate(wallet, newServerConfig)
|
const transformedConfig = await walletValidate(walletDef, newServerConfig)
|
||||||
if (transformedConfig) {
|
if (transformedConfig) {
|
||||||
newServerConfig = Object.assign(newServerConfig, transformedConfig)
|
newServerConfig = Object.assign(newServerConfig, transformedConfig)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (e) {
|
||||||
valid = false
|
newServerConfig = {}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (valid) await setServerConfig(newServerConfig, { priorityOnly })
|
// check if it misses send or receive configs
|
||||||
}
|
const isReadyToSend = canSend && isConfigured({ fields: walletDef.fields, config: newConfig, clientOnly: true })
|
||||||
}, [hasClientConfig, hasServerConfig, setClientConfig, setServerConfig, wallet])
|
const isReadyToReceive = canReceive && isConfigured({ fields: walletDef.fields, config: newConfig, serverOnly: true })
|
||||||
|
|
||||||
const clearConfig = useCallback(async ({ logger, clientOnly }) => {
|
// console.log('New client config', newClientConfig)
|
||||||
if (hasClientConfig) {
|
// console.log('New server config', newServerConfig)
|
||||||
clearClientConfig()
|
// console.log('Sender', isReadyToSend, 'Receiver', isReadyToReceive, 'enabled', enabled)
|
||||||
wallet.disablePayments()
|
|
||||||
logger.ok('wallet detached for payments')
|
|
||||||
}
|
|
||||||
if (hasServerConfig && !clientOnly) await clearServerConfig()
|
|
||||||
}, [hasClientConfig, hasServerConfig, clearClientConfig, clearServerConfig, wallet])
|
|
||||||
|
|
||||||
return [config, saveConfig, clearConfig]
|
// client test
|
||||||
}
|
if (!skipTests && isReadyToSend) {
|
||||||
|
try {
|
||||||
function isConfigured ({ fields, config }) {
|
// XXX: testSendPayment can return a new config (e.g. lnc)
|
||||||
if (!config || !fields) return false
|
const newerConfig = await walletDef.testSendPayment?.(newClientConfig, { me, logger })
|
||||||
|
if (newerConfig) {
|
||||||
// a wallet is configured if all of its required fields are set
|
newClientConfig = Object.assign(newClientConfig, newerConfig)
|
||||||
let val = fields.every(f => {
|
}
|
||||||
return f.optional ? true : !!config?.[f.name]
|
} catch (err) {
|
||||||
})
|
logger.error(err.message)
|
||||||
|
throw err
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// set server config (will create wallet if it doesn't exist) (it is also testing receive config)
|
||||||
|
const mutation = generateMutation(walletDef)
|
||||||
|
const variables = {
|
||||||
|
...newServerConfig,
|
||||||
|
id: currentWallet?.id,
|
||||||
|
settings: {
|
||||||
|
autoWithdrawThreshold: Number(autoWithdrawThreshold),
|
||||||
|
autoWithdrawMaxFeePercent: Number(autoWithdrawMaxFeePercent),
|
||||||
|
priority,
|
||||||
|
enabled: enabled && (isReadyToSend || isReadyToReceive)
|
||||||
|
},
|
||||||
|
canSend: isReadyToSend,
|
||||||
|
canReceive: isReadyToReceive,
|
||||||
|
priorityOnly
|
||||||
|
}
|
||||||
|
const { data: mutationResult, errors: mutationErrors } = await client.mutate({
|
||||||
|
mutation,
|
||||||
|
variables
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (mutationErrors) {
|
||||||
|
throw new Error(mutationErrors[0].message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// grab and update wallet ref
|
||||||
|
const newWallet = mutationResult[generateResolverName(walletDef.walletField)]
|
||||||
|
innerSetCurrentWallet(newWallet)
|
||||||
|
|
||||||
|
// set client config
|
||||||
|
const writeVault = openVault(client, me, newWallet, {})
|
||||||
|
try {
|
||||||
|
await writeVault.set(walletDef.name, newClientConfig)
|
||||||
|
} finally {
|
||||||
|
await writeVault.close()
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
client.refetchQueries({ include: ['WalletLogs'] })
|
client.refetchQueries({ include: ['WalletLogs'] })
|
||||||
refetchConfig()
|
await refreshConfig()
|
||||||
}
|
}
|
||||||
}, [client, walletId])
|
}, [config, currentWallet, canSend, canReceive])
|
||||||
|
|
||||||
const clearConfig = useCallback(async () => {
|
const clearConfig = useCallback(async ({ logger, clientOnly, ...options }) => {
|
||||||
// only remove wallet if there is a wallet to remove
|
// only remove wallet if there is a wallet to remove
|
||||||
if (!walletId) return
|
if (!currentWallet?.id) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await client.mutate({
|
const clearVault = openVault(client, me, currentWallet, {})
|
||||||
mutation: REMOVE_WALLET,
|
try {
|
||||||
variables: { id: walletId }
|
await clearVault.clear(walletDef?.name, { onlyFromLocalStorage: clientOnly })
|
||||||
})
|
} catch (e) {
|
||||||
|
toaster.danger(`failed to clear client config for ${walletDef.name}: ${e.message}`)
|
||||||
|
} finally {
|
||||||
|
await clearVault.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!clientOnly) {
|
||||||
|
try {
|
||||||
|
await client.mutate({
|
||||||
|
mutation: REMOVE_WALLET,
|
||||||
|
variables: { id: currentWallet.id }
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
toaster.danger(`failed to remove wallet ${currentWallet.id}: ${e.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
client.refetchQueries({ include: ['WalletLogs'] })
|
client.refetchQueries({ include: ['WalletLogs'] })
|
||||||
refetchConfig()
|
await refreshConfig()
|
||||||
}
|
}
|
||||||
}, [client, walletId])
|
}, [config, currentWallet])
|
||||||
|
|
||||||
return [config, saveConfig, clearConfig]
|
return [config, saveConfig, clearConfig]
|
||||||
}
|
}
|
||||||
|
@ -350,22 +386,30 @@ function generateMutation (wallet) {
|
||||||
headerArgs += wallet.fields
|
headerArgs += wallet.fields
|
||||||
.filter(isServerField)
|
.filter(isServerField)
|
||||||
.map(f => {
|
.map(f => {
|
||||||
let arg = `$${f.name}: String`
|
const arg = `$${f.name}: String`
|
||||||
if (!f.optional) {
|
// required fields are checked server-side
|
||||||
arg += '!'
|
// if (!f.optional) {
|
||||||
}
|
// arg += '!'
|
||||||
|
// }
|
||||||
return arg
|
return arg
|
||||||
}).join(', ')
|
}).join(', ')
|
||||||
headerArgs += ', $settings: AutowithdrawSettings!, $priorityOnly: Boolean'
|
headerArgs += ', $settings: AutowithdrawSettings!, $priorityOnly: Boolean, $canSend: Boolean!, $canReceive: Boolean!'
|
||||||
|
|
||||||
let inputArgs = 'id: $id, '
|
let inputArgs = 'id: $id, '
|
||||||
inputArgs += wallet.fields
|
inputArgs += wallet.fields
|
||||||
.filter(isServerField)
|
.filter(isServerField)
|
||||||
.map(f => `${f.name}: $${f.name}`).join(', ')
|
.map(f => `${f.name}: $${f.name}`).join(', ')
|
||||||
inputArgs += ', settings: $settings, priorityOnly: $priorityOnly'
|
inputArgs += ', settings: $settings, priorityOnly: $priorityOnly, canSend: $canSend, canReceive: $canReceive,'
|
||||||
|
|
||||||
return gql`mutation ${resolverName}(${headerArgs}) {
|
return gql`mutation ${resolverName}(${headerArgs}) {
|
||||||
${resolverName}(${inputArgs})
|
${resolverName}(${inputArgs}) {
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
enabled,
|
||||||
|
priority,
|
||||||
|
canReceive,
|
||||||
|
canSend
|
||||||
|
}
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -377,20 +421,6 @@ export function getWalletByType (type) {
|
||||||
return walletDefs.find(def => def.walletType === 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) {
|
export function walletPrioritySort (w1, w2) {
|
||||||
const delta = w1.priority - w2.priority
|
const delta = w1.priority - w2.priority
|
||||||
// delta is NaN if either priority is undefined
|
// delta is NaN if either priority is undefined
|
||||||
|
@ -416,37 +446,16 @@ export function useWallets () {
|
||||||
const resetClient = useCallback(async (wallet) => {
|
const resetClient = useCallback(async (wallet) => {
|
||||||
for (const w of wallets) {
|
for (const w of wallets) {
|
||||||
if (w.canSend) {
|
if (w.canSend) {
|
||||||
await w.delete({ clientOnly: true })
|
await w.delete({ clientOnly: true, onlyFromLocalStorage: true })
|
||||||
}
|
}
|
||||||
await w.deleteLogs({ clientOnly: true })
|
await w.deleteLogs({ clientOnly: true })
|
||||||
}
|
}
|
||||||
}, [wallets])
|
}, wallets)
|
||||||
|
|
||||||
return { wallets, resetClient }
|
const [walletsReady, setWalletsReady] = useState([])
|
||||||
}
|
useEffect(() => {
|
||||||
|
setWalletsReady(wallets.filter(w => w))
|
||||||
function getStorageKey (name, me) {
|
}, wallets)
|
||||||
let storageKey = `wallet:${name}`
|
|
||||||
|
return { wallets: walletsReady, resetClient }
|
||||||
// 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))
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,9 @@ import { lnAddrAutowithdrawSchema } from '@/lib/validate'
|
||||||
|
|
||||||
export const name = 'lightning-address'
|
export const name = 'lightning-address'
|
||||||
export const shortName = 'lnAddr'
|
export const shortName = 'lnAddr'
|
||||||
|
export const walletType = 'LIGHTNING_ADDRESS'
|
||||||
|
export const walletField = 'walletLightningAddress'
|
||||||
|
export const fieldValidation = lnAddrAutowithdrawSchema
|
||||||
|
|
||||||
export const fields = [
|
export const fields = [
|
||||||
{
|
{
|
||||||
|
@ -17,9 +20,3 @@ export const card = {
|
||||||
subtitle: 'autowithdraw to a lightning address',
|
subtitle: 'autowithdraw to a lightning address',
|
||||||
badges: ['receive only']
|
badges: ['receive only']
|
||||||
}
|
}
|
||||||
|
|
||||||
export const fieldValidation = lnAddrAutowithdrawSchema
|
|
||||||
|
|
||||||
export const walletType = 'LIGHTNING_ADDRESS'
|
|
||||||
|
|
||||||
export const walletField = 'walletLightningAddress'
|
|
||||||
|
|
|
@ -1,6 +1,9 @@
|
||||||
import { lnbitsSchema } from '@/lib/validate'
|
import { lnbitsSchema } from '@/lib/validate'
|
||||||
|
|
||||||
export const name = 'lnbits'
|
export const name = 'lnbits'
|
||||||
|
export const walletType = 'LNBITS'
|
||||||
|
export const walletField = 'walletLNbits'
|
||||||
|
export const fieldValidation = lnbitsSchema
|
||||||
|
|
||||||
export const fields = [
|
export const fields = [
|
||||||
{
|
{
|
||||||
|
@ -31,9 +34,3 @@ export const card = {
|
||||||
subtitle: 'use [LNbits](https://lnbits.com/) for payments',
|
subtitle: 'use [LNbits](https://lnbits.com/) for payments',
|
||||||
badges: ['send & receive']
|
badges: ['send & receive']
|
||||||
}
|
}
|
||||||
|
|
||||||
export const fieldValidation = lnbitsSchema
|
|
||||||
|
|
||||||
export const walletType = 'LNBITS'
|
|
||||||
|
|
||||||
export const walletField = 'walletLNbits'
|
|
||||||
|
|
|
@ -1,6 +1,10 @@
|
||||||
import { lncSchema } from '@/lib/validate'
|
import { lncSchema } from '@/lib/validate'
|
||||||
|
|
||||||
export const name = 'lnc'
|
export const name = 'lnc'
|
||||||
|
export const walletType = 'LNC'
|
||||||
|
export const walletField = 'walletLNC'
|
||||||
|
export const clientOnly = true
|
||||||
|
export const fieldValidation = lncSchema
|
||||||
|
|
||||||
export const fields = [
|
export const fields = [
|
||||||
{
|
{
|
||||||
|
@ -35,5 +39,3 @@ export const card = {
|
||||||
subtitle: 'use Lightning Node Connect for LND payments',
|
subtitle: 'use Lightning Node Connect for LND payments',
|
||||||
badges: ['send only', 'budgetable']
|
badges: ['send only', 'budgetable']
|
||||||
}
|
}
|
||||||
|
|
||||||
export const fieldValidation = lncSchema
|
|
||||||
|
|
|
@ -1,6 +1,9 @@
|
||||||
import { LNDAutowithdrawSchema } from '@/lib/validate'
|
import { LNDAutowithdrawSchema } from '@/lib/validate'
|
||||||
|
|
||||||
export const name = 'lnd'
|
export const name = 'lnd'
|
||||||
|
export const walletType = 'LND'
|
||||||
|
export const walletField = 'walletLND'
|
||||||
|
export const fieldValidation = LNDAutowithdrawSchema
|
||||||
|
|
||||||
export const fields = [
|
export const fields = [
|
||||||
{
|
{
|
||||||
|
@ -39,9 +42,3 @@ export const card = {
|
||||||
subtitle: 'autowithdraw to your Lightning Labs node',
|
subtitle: 'autowithdraw to your Lightning Labs node',
|
||||||
badges: ['receive only']
|
badges: ['receive only']
|
||||||
}
|
}
|
||||||
|
|
||||||
export const fieldValidation = LNDAutowithdrawSchema
|
|
||||||
|
|
||||||
export const walletType = 'LND'
|
|
||||||
|
|
||||||
export const walletField = 'walletLND'
|
|
||||||
|
|
|
@ -4,6 +4,9 @@ import { nwcSchema } from '@/lib/validate'
|
||||||
import { finalizeEvent, nip04, verifyEvent } from 'nostr-tools'
|
import { finalizeEvent, nip04, verifyEvent } from 'nostr-tools'
|
||||||
|
|
||||||
export const name = 'nwc'
|
export const name = 'nwc'
|
||||||
|
export const walletType = 'NWC'
|
||||||
|
export const walletField = 'walletNWC'
|
||||||
|
export const fieldValidation = nwcSchema
|
||||||
|
|
||||||
export const fields = [
|
export const fields = [
|
||||||
{
|
{
|
||||||
|
@ -30,12 +33,6 @@ export const card = {
|
||||||
badges: ['send & receive', 'budgetable']
|
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 } = {}) {
|
export async function nwcCall ({ nwcUrl, method, params }, { logger, timeout } = {}) {
|
||||||
const { relayUrl, walletPubkey, secret } = parseNwcUrl(nwcUrl)
|
const { relayUrl, walletPubkey, secret } = parseNwcUrl(nwcUrl)
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,9 @@
|
||||||
import { phoenixdSchema } from '@/lib/validate'
|
import { phoenixdSchema } from '@/lib/validate'
|
||||||
|
|
||||||
export const name = 'phoenixd'
|
export const name = 'phoenixd'
|
||||||
|
export const walletType = 'PHOENIXD'
|
||||||
|
export const walletField = 'walletPhoenixd'
|
||||||
|
export const fieldValidation = phoenixdSchema
|
||||||
|
|
||||||
// configure wallet fields
|
// configure wallet fields
|
||||||
export const fields = [
|
export const fields = [
|
||||||
|
@ -38,8 +41,3 @@ export const card = {
|
||||||
|
|
||||||
// phoenixd::TODO
|
// phoenixd::TODO
|
||||||
// set validation schema
|
// set validation schema
|
||||||
export const fieldValidation = phoenixdSchema
|
|
||||||
|
|
||||||
export const walletType = 'PHOENIXD'
|
|
||||||
|
|
||||||
export const walletField = 'walletPhoenixd'
|
|
||||||
|
|
|
@ -1,23 +1,31 @@
|
||||||
|
// import server side wallets
|
||||||
import * as lnd from 'wallets/lnd/server'
|
import * as lnd from 'wallets/lnd/server'
|
||||||
import * as cln from 'wallets/cln/server'
|
import * as cln from 'wallets/cln/server'
|
||||||
import * as lnAddr from 'wallets/lightning-address/server'
|
import * as lnAddr from 'wallets/lightning-address/server'
|
||||||
import * as lnbits from 'wallets/lnbits/server'
|
import * as lnbits from 'wallets/lnbits/server'
|
||||||
import * as nwc from 'wallets/nwc/server'
|
import * as nwc from 'wallets/nwc/server'
|
||||||
import * as phoenixd from 'wallets/phoenixd/server'
|
import * as phoenixd from 'wallets/phoenixd/server'
|
||||||
|
|
||||||
|
// we import only the metadata of client side wallets
|
||||||
|
import * as blink from 'wallets/blink'
|
||||||
|
import * as lnc from 'wallets/lnc'
|
||||||
|
import * as webln from 'wallets/webln'
|
||||||
|
|
||||||
import { addWalletLog } from '@/api/resolvers/wallet'
|
import { addWalletLog } from '@/api/resolvers/wallet'
|
||||||
import walletDefs from 'wallets/server'
|
import walletDefs from 'wallets/server'
|
||||||
import { parsePaymentRequest } from 'ln-service'
|
import { parsePaymentRequest } from 'ln-service'
|
||||||
import { toPositiveNumber } from '@/lib/validate'
|
import { toPositiveNumber } from '@/lib/validate'
|
||||||
import { PAID_ACTION_TERMINAL_STATES } from '@/lib/constants'
|
import { PAID_ACTION_TERMINAL_STATES } from '@/lib/constants'
|
||||||
import { withTimeout } from '@/lib/time'
|
import { withTimeout } from '@/lib/time'
|
||||||
export default [lnd, cln, lnAddr, lnbits, nwc, phoenixd]
|
|
||||||
|
export default [lnd, cln, lnAddr, lnbits, nwc, phoenixd, blink, lnc, webln]
|
||||||
|
|
||||||
const MAX_PENDING_INVOICES_PER_WALLET = 25
|
const MAX_PENDING_INVOICES_PER_WALLET = 25
|
||||||
|
|
||||||
export async function createInvoice (userId, { msats, description, descriptionHash, expiry = 360 }, { models }) {
|
export async function createInvoice (userId, { msats, description, descriptionHash, expiry = 360 }, { models }) {
|
||||||
// get the wallets in order of priority
|
// get the wallets in order of priority
|
||||||
const wallets = await models.wallet.findMany({
|
const wallets = await models.wallet.findMany({
|
||||||
where: { userId, enabled: true },
|
where: { userId, enabled: true, canReceive: true },
|
||||||
include: {
|
include: {
|
||||||
user: true
|
user: true
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { useEffect } from 'react'
|
||||||
export * from 'wallets/webln'
|
export * from 'wallets/webln'
|
||||||
|
|
||||||
export const sendPayment = async (bolt11) => {
|
export const sendPayment = async (bolt11) => {
|
||||||
|
@ -19,3 +20,32 @@ export const sendPayment = async (bolt11) => {
|
||||||
|
|
||||||
return response.preimage
|
return response.preimage
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isAvailable () {
|
||||||
|
return typeof window !== 'undefined' && window?.weblnEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WebLnProvider ({ children }) {
|
||||||
|
useEffect(() => {
|
||||||
|
const onEnable = () => {
|
||||||
|
window.weblnEnabled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDisable = () => {
|
||||||
|
window.weblnEnabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!window.webln) onDisable()
|
||||||
|
else onEnable()
|
||||||
|
|
||||||
|
window.addEventListener('webln:enabled', onEnable)
|
||||||
|
// event is not fired by Alby browser extension but added here for sake of completeness
|
||||||
|
window.addEventListener('webln:disabled', onDisable)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('webln:enabled', onEnable)
|
||||||
|
window.removeEventListener('webln:disabled', onDisable)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return children
|
||||||
|
}
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
import { useEffect } from 'react'
|
|
||||||
import { useWallet } from 'wallets'
|
|
||||||
|
|
||||||
export const name = 'webln'
|
export const name = 'webln'
|
||||||
|
export const walletType = 'WEBLN'
|
||||||
|
export const walletField = 'walletWebLN'
|
||||||
|
export const clientOnly = true
|
||||||
|
|
||||||
export const fields = []
|
export const fields = []
|
||||||
|
|
||||||
export const fieldValidation = ({ enabled }) => {
|
export const fieldValidation = ({ enabled }) => {
|
||||||
if (typeof window.webln === 'undefined') {
|
if (typeof window?.webln === 'undefined') {
|
||||||
// don't prevent disabling WebLN if no WebLN provider found
|
// don't prevent disabling WebLN if no WebLN provider found
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
return {
|
return {
|
||||||
|
@ -22,27 +22,3 @@ export const card = {
|
||||||
subtitle: 'use a [WebLN provider](https://www.webln.guide/ressources/webln-providers) for payments',
|
subtitle: 'use a [WebLN provider](https://www.webln.guide/ressources/webln-providers) for payments',
|
||||||
badges: ['send only']
|
badges: ['send only']
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function WebLnProvider ({ children }) {
|
|
||||||
const wallet = useWallet(name)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onEnable = () => {
|
|
||||||
wallet.enablePayments()
|
|
||||||
}
|
|
||||||
|
|
||||||
const onDisable = () => {
|
|
||||||
wallet.disablePayments()
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('webln:enabled', onEnable)
|
|
||||||
// event is not fired by Alby browser extension but added here for sake of completeness
|
|
||||||
window.addEventListener('webln:disabled', onDisable)
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('webln:enabled', onEnable)
|
|
||||||
window.removeEventListener('webln:disabled', onDisable)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return children
|
|
||||||
}
|
|
||||||
|
|
Loading…
Reference in New Issue