complete fantasy scaffolding
This commit is contained in:
parent
b61c957cc7
commit
da020cf899
|
@ -1,36 +1,27 @@
|
|||
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) => {
|
||||
getVaultEntry: async (parent, { 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
|
||||
userId: me.id
|
||||
}
|
||||
}
|
||||
})
|
||||
return k
|
||||
},
|
||||
getVaultEntries: async (parent, { ownerId, ownerType, keysFilter }, { me, models }, info) => {
|
||||
getVaultEntries: async (parent, { 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
|
||||
|
@ -42,77 +33,11 @@ export default {
|
|||
}
|
||||
},
|
||||
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 }) => {
|
||||
// atomic vault migration
|
||||
updateVaultKey: async (parent, { entries, hash }, { me, models }) => {
|
||||
if (!me) throw new GqlAuthenticationError()
|
||||
if (!hash) throw new GqlInputError('hash required')
|
||||
const txs = []
|
||||
|
||||
const { vaultKeyHash: oldKeyHash } = await models.user.findUnique({ where: { id: me.id } })
|
||||
if (oldKeyHash) {
|
||||
|
@ -122,27 +47,32 @@ export default {
|
|||
return true
|
||||
}
|
||||
} else {
|
||||
await models.user.update({
|
||||
txs.push(models.user.update({
|
||||
where: { id: me.id },
|
||||
data: { vaultKeyHash: hash }
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
txs.push(models.vaultEntry.upsert({
|
||||
where: { userId: me.id, key: entry.key },
|
||||
update: { key: entry.key, value: entry.value },
|
||||
create: { key: entry.key, value: entry.value, userId: me.id, walletId: entry.walletId }
|
||||
}))
|
||||
}
|
||||
await models.prisma.$transaction(txs)
|
||||
return true
|
||||
},
|
||||
clearVault: async (parent, args, { me, models }) => {
|
||||
if (!me) throw new GqlAuthenticationError()
|
||||
const txs = []
|
||||
txs.push(models.user.update({
|
||||
where: { id: me.id },
|
||||
data: { vaultKeyHash: '' }
|
||||
}))
|
||||
txs.push(models.vaultEntry.deleteMany({ where: { userId: me.id } }))
|
||||
await models.prisma.$transaction(txs)
|
||||
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?.()
|
||||
if (!ownerInterfaces?.some((iface) => iface.name === 'VaultOwner')) {
|
||||
throw new GqlInputError('owner must implement VaultOwner interface but ' + ownerType + ' does not')
|
||||
}
|
||||
}
|
||||
|
|
|
@ -161,6 +161,9 @@ const resolvers = {
|
|||
where: {
|
||||
userId: me.id,
|
||||
id: Number(id)
|
||||
},
|
||||
include: {
|
||||
vaultEntries: true
|
||||
}
|
||||
})
|
||||
},
|
||||
|
@ -173,40 +176,29 @@ const resolvers = {
|
|||
where: {
|
||||
userId: me.id,
|
||||
type
|
||||
},
|
||||
include: {
|
||||
vaultEntries: true
|
||||
}
|
||||
})
|
||||
return wallet
|
||||
},
|
||||
wallets: async (parent, { includeReceivers = true, includeSenders = true, onlyEnabled = false, prioritySort = undefined }, { me, models }) => {
|
||||
wallets: async (parent, args, { me, models }) => {
|
||||
if (!me) {
|
||||
throw new GqlAuthenticationError()
|
||||
}
|
||||
|
||||
const filter = {
|
||||
userId: me.id
|
||||
}
|
||||
|
||||
if (includeReceivers && includeSenders) {
|
||||
filter.OR = [
|
||||
{ canReceive: true },
|
||||
{ canSend: true }
|
||||
]
|
||||
} else if (includeReceivers) {
|
||||
filter.canReceive = true
|
||||
} else if (includeSenders) {
|
||||
filter.canSend = true
|
||||
}
|
||||
if (onlyEnabled) {
|
||||
filter.enabled = true
|
||||
}
|
||||
|
||||
const out = await models.wallet.findMany({
|
||||
where: filter,
|
||||
return await models.wallet.findMany({
|
||||
include: {
|
||||
vaultEntries: true
|
||||
},
|
||||
where: {
|
||||
userId: me.id
|
||||
},
|
||||
orderBy: {
|
||||
priority: prioritySort
|
||||
priority: 'asc'
|
||||
}
|
||||
})
|
||||
return out
|
||||
},
|
||||
withdrawl: getWithdrawl,
|
||||
numBolt11s: async (parent, args, { me, models, lnd }) => {
|
||||
|
|
|
@ -46,7 +46,7 @@ export default gql`
|
|||
disableFreebies: Boolean
|
||||
}
|
||||
|
||||
type User implements VaultOwner {
|
||||
type User {
|
||||
id: ID!
|
||||
createdAt: Date!
|
||||
name: String
|
||||
|
|
|
@ -1,11 +1,7 @@
|
|||
import { gql } from 'graphql-tag'
|
||||
|
||||
export default gql`
|
||||
interface VaultOwner {
|
||||
id: ID!
|
||||
}
|
||||
|
||||
type Vault {
|
||||
type VaultEntry {
|
||||
id: ID!
|
||||
key: String!
|
||||
value: String!
|
||||
|
@ -13,16 +9,19 @@ export default gql`
|
|||
updatedAt: Date!
|
||||
}
|
||||
|
||||
input VaultEntryInput {
|
||||
key: String!
|
||||
value: String!
|
||||
walletId: ID
|
||||
}
|
||||
|
||||
extend type Query {
|
||||
getVaultEntry(ownerId:ID!, ownerType:String!, key: String!): Vault
|
||||
getVaultEntries(ownerId:ID!, ownerType:String!, keysFilter: [String]): [Vault!]!
|
||||
getVaultEntry(key: String!): VaultEntry
|
||||
getVaultEntries(keysFilter: [String!]): [VaultEntry!]!
|
||||
}
|
||||
|
||||
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
|
||||
updateVaultKey(entries: [VaultEntryInput!]!, hash: String!): Boolean
|
||||
}
|
||||
`
|
||||
|
|
|
@ -83,7 +83,7 @@ const typeDefs = `
|
|||
deleteWalletLogs(wallet: String): Boolean
|
||||
}
|
||||
|
||||
type Wallet implements VaultOwner {
|
||||
type Wallet {
|
||||
id: ID!
|
||||
createdAt: Date!
|
||||
updatedAt: Date!
|
||||
|
@ -93,6 +93,7 @@ const typeDefs = `
|
|||
wallet: WalletDetails!
|
||||
canReceive: Boolean!
|
||||
canSend: Boolean!
|
||||
vaultEntries: [VaultEntry!]!
|
||||
}
|
||||
|
||||
input AutowithdrawSettings {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useMe } from './me'
|
||||
import { useShowModal } from './modal'
|
||||
import useVault, { useVaultConfigurator, useVaultMigration } from './use-vault'
|
||||
import useVault, { useVaultConfigurator, useVaultMigration } from './vault/use-vault'
|
||||
import { Button, InputGroup } from 'react-bootstrap'
|
||||
import { Form, Input, PasswordInput, SubmitButton } from './form'
|
||||
import bip39Words from '@/lib/bip39-words'
|
||||
|
|
|
@ -13,7 +13,7 @@ import { usePaidMutation } from './use-paid-mutation'
|
|||
import { ACT_MUTATION } from '@/fragments/paidAction'
|
||||
import { meAnonSats } from '@/lib/apollo'
|
||||
import { BoostItemInput } from './adv-post-form'
|
||||
import { useWallet } from '../wallets'
|
||||
import { useWallet } from '../wallets/common'
|
||||
|
||||
const defaultTips = [100, 1000, 10_000, 100_000]
|
||||
|
||||
|
|
|
@ -22,7 +22,7 @@ import SearchIcon from '../../svgs/search-line.svg'
|
|||
import classNames from 'classnames'
|
||||
import SnIcon from '@/svgs/sn.svg'
|
||||
import { useHasNewNotes } from '../use-has-new-notes'
|
||||
import { useWallets } from 'wallets'
|
||||
import { useWallets } from '@/wallets/common'
|
||||
import SwitchAccountList, { useAccounts } from '@/components/account'
|
||||
import { useShowModal } from '@/components/modal'
|
||||
import { unsetLocalKey as resetVaultKey } from '@/components/use-vault'
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { useCallback, useMemo } from 'react'
|
||||
import { useMe } from './me'
|
||||
import { gql, useApolloClient, useMutation } from '@apollo/client'
|
||||
import { useWallet } from 'wallets'
|
||||
import { useWallet } from '@/wallets/common'
|
||||
import { FAST_POLL_INTERVAL, JIT_INVOICE_TIMEOUT_MS } from '@/lib/constants'
|
||||
import { INVOICE } from '@/fragments/wallet'
|
||||
import Invoice from '@/components/invoice'
|
||||
|
|
|
@ -2,7 +2,7 @@ import { QRCodeSVG } from 'qrcode.react'
|
|||
import { CopyInput, InputSkeleton } from './form'
|
||||
import InvoiceStatus from './invoice-status'
|
||||
import { useEffect } from 'react'
|
||||
import { useWallet } from 'wallets'
|
||||
import { useWallet } from '@/wallets/common'
|
||||
import Bolt11Info from './bolt11-info'
|
||||
|
||||
export default function Qr ({ asIs, value, useWallet: automated, statusVariant, description, status }) {
|
||||
|
|
|
@ -1,6 +1,10 @@
|
|||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
|
||||
function useIndexedDB (dbName, storeName, version = 1, indices = []) {
|
||||
export function getDbName (userId) {
|
||||
return `app:storage${userId ? `:${userId}` : ''}`
|
||||
}
|
||||
|
||||
function useIndexedDB ({ dbName, storeName, options = { keyPath: 'id', autoIncrement: true }, indices = [], version = 1 }) {
|
||||
const [db, setDb] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [notSupported, setNotSupported] = useState(false)
|
||||
|
@ -58,7 +62,7 @@ function useIndexedDB (dbName, storeName, version = 1, indices = []) {
|
|||
request.onupgradeneeded = (event) => {
|
||||
const database = event.target.result
|
||||
try {
|
||||
const store = database.createObjectStore(storeName, { keyPath: 'id', autoIncrement: true })
|
||||
const store = database.createObjectStore(storeName, options)
|
||||
|
||||
indices.forEach(index => {
|
||||
store.createIndex(index.name, index.keyPath, index.options)
|
||||
|
@ -141,20 +145,15 @@ function useIndexedDB (dbName, storeName, version = 1, indices = []) {
|
|||
})
|
||||
}, [queueOperation, storeName])
|
||||
|
||||
const update = useCallback((key, value) => {
|
||||
const set = useCallback((key, value) => {
|
||||
return queueOperation((db) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(storeName, 'readwrite')
|
||||
const store = transaction.objectStore(storeName)
|
||||
const request = store.get(key)
|
||||
const request = store.put(value, key)
|
||||
|
||||
request.onerror = () => reject(new Error('Error updating data'))
|
||||
request.onsuccess = () => {
|
||||
const updatedValue = { ...request.result, ...value }
|
||||
const updateRequest = store.put(updatedValue)
|
||||
updateRequest.onerror = () => reject(new Error('Error updating data'))
|
||||
updateRequest.onsuccess = () => resolve(updateRequest.result)
|
||||
}
|
||||
request.onerror = () => reject(new Error('Error setting data'))
|
||||
request.onsuccess = () => resolve(request.result)
|
||||
})
|
||||
})
|
||||
}, [queueOperation, storeName])
|
||||
|
@ -286,7 +285,7 @@ function useIndexedDB (dbName, storeName, version = 1, indices = []) {
|
|||
})
|
||||
}, [queueOperation, storeName])
|
||||
|
||||
return { add, get, getAll, update, remove, clear, getByIndex, getAllByIndex, getPage, error, notSupported }
|
||||
return { add, get, getAll, set, remove, clear, getByIndex, getAllByIndex, getPage, error, notSupported }
|
||||
}
|
||||
|
||||
export default useIndexedDB
|
||||
|
|
|
@ -1,453 +0,0 @@
|
|||
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 { 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 [vaultKeyHash, setVaultKeyHashLocal] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!me) return
|
||||
(async () => {
|
||||
const config = await openConfig(me.id)
|
||||
try {
|
||||
let localVaultKey = await config.get('key')
|
||||
const keyHash = me?.privates?.vaultKeyHash || vaultKeyHash
|
||||
if ((!keyHash && localVaultKey?.hash) || (localVaultKey?.hash && keyHash && localVaultKey?.hash !== keyHash)) {
|
||||
// If the hash stored in the server does not match the hash of the local key,
|
||||
// we can tell that the key is outdated (reset by another device or other reasons)
|
||||
// in this case we clear the local key and let the user re-enter the passphrase
|
||||
console.log('vault key hash mismatch, clearing local key', localVaultKey?.hash, '!=', keyHash)
|
||||
localVaultKey = null
|
||||
await config.unset('key')
|
||||
}
|
||||
innerSetVaultKey(localVaultKey)
|
||||
} catch (e) {
|
||||
toaster.danger('error loading vault configuration ' + e.message)
|
||||
} finally {
|
||||
await config.close()
|
||||
}
|
||||
})()
|
||||
}, [me?.privates?.vaultKeyHash])
|
||||
|
||||
// clear vault: remove everything and reset the key
|
||||
const [clearVault] = useMutation(CLEAR_VAULT, {
|
||||
onCompleted: async () => {
|
||||
const config = await openConfig(me.id)
|
||||
try {
|
||||
await config.unset('key')
|
||||
innerSetVaultKey(null)
|
||||
} catch (e) {
|
||||
toaster.danger('error clearing vault ' + e.message)
|
||||
} finally {
|
||||
await config.close()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// initialize the vault and set a vault key
|
||||
const setVaultKey = useCallback(async (passphrase) => {
|
||||
const config = await openConfig(me.id)
|
||||
try {
|
||||
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)
|
||||
setVaultKeyHashLocal(vaultKey.hash)
|
||||
await config.set('key', vaultKey)
|
||||
} catch (e) {
|
||||
toaster.danger('error setting vault key ' + e.message)
|
||||
} finally {
|
||||
await config.close()
|
||||
}
|
||||
}, [setVaultKeyHash])
|
||||
|
||||
// disconnect the user from the vault (will not clear or reset the passphrase, use clearVault for that)
|
||||
const disconnectVault = useCallback(async () => {
|
||||
const config = await openConfig(me.id)
|
||||
try {
|
||||
await config.unset('key')
|
||||
innerSetVaultKey(null)
|
||||
} catch (e) {
|
||||
toaster.danger('error disconnecting vault ' + e.message)
|
||||
} finally {
|
||||
await config.close()
|
||||
}
|
||||
}, [innerSetVaultKey])
|
||||
|
||||
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 {
|
||||
console.log('could not set vault entry:', entryName)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('failed migrate to vault:', entryName, e)
|
||||
}
|
||||
}
|
||||
await storage.close()
|
||||
}
|
||||
return migratedCount
|
||||
}, [me?.id])
|
||||
|
||||
return migrate
|
||||
}
|
||||
|
||||
export async function unsetLocalKey (userId) {
|
||||
const config = await openConfig(userId)
|
||||
await config.unset('key')
|
||||
await config.close()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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?.hash, 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'
|
||||
})
|
||||
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 }
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
|
@ -0,0 +1,145 @@
|
|||
import { UPDATE_VAULT_KEY } from '@/fragments/users'
|
||||
import { useMutation, useQuery } from '@apollo/client'
|
||||
import { useMe } from '../me'
|
||||
import { useToast } from '../toast'
|
||||
import useIndexedDB, { getDbName } from '../use-indexeddb'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { E_VAULT_KEY_EXISTS } from '@/lib/error'
|
||||
import { CLEAR_VAULT, GET_VAULT_ENTRIES } from '@/fragments/vault'
|
||||
import { toHex } from '@/lib/hex'
|
||||
import { decryptData, encryptData } from './use-vault'
|
||||
|
||||
const useImperativeQuery = (query) => {
|
||||
const { refetch } = useQuery(query, { skip: true })
|
||||
|
||||
const imperativelyCallQuery = (variables) => {
|
||||
return refetch(variables)
|
||||
}
|
||||
|
||||
return imperativelyCallQuery
|
||||
}
|
||||
|
||||
export function useVaultConfigurator () {
|
||||
const { me } = useMe()
|
||||
const toaster = useToast()
|
||||
const { set, get, remove } = useIndexedDB({ dbName: getDbName(me?.id), storeName: 'vault' })
|
||||
const [updateVaultKey] = useMutation(UPDATE_VAULT_KEY)
|
||||
const getVaultEntries = useImperativeQuery(GET_VAULT_ENTRIES)
|
||||
const [key, setKey] = useState(null)
|
||||
const [keyHash, setKeyHash] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!me) return
|
||||
(async () => {
|
||||
try {
|
||||
let localVaultKey = await get('key')
|
||||
const localKeyHash = me?.privates?.vaultKeyHash || keyHash
|
||||
if (localVaultKey?.hash && localVaultKey?.hash !== localKeyHash) {
|
||||
// If the hash stored in the server does not match the hash of the local key,
|
||||
// we can tell that the key is outdated (reset by another device or other reasons)
|
||||
// in this case we clear the local key and let the user re-enter the passphrase
|
||||
console.log('vault key hash mismatch, clearing local key', localVaultKey?.hash, '!=', localKeyHash)
|
||||
localVaultKey = null
|
||||
await remove('key')
|
||||
}
|
||||
setKey(localVaultKey)
|
||||
} catch (e) {
|
||||
toaster.danger('error loading vault configuration ' + e.message)
|
||||
}
|
||||
})()
|
||||
}, [me?.privates?.vaultKeyHash, keyHash, get, remove])
|
||||
|
||||
// clear vault: remove everything and reset the key
|
||||
const [clearVault] = useMutation(CLEAR_VAULT, {
|
||||
onCompleted: async () => {
|
||||
try {
|
||||
await remove('key')
|
||||
setKey(null)
|
||||
setKeyHash(null)
|
||||
} catch (e) {
|
||||
toaster.danger('error clearing vault ' + e.message)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// initialize the vault and set a vault key
|
||||
const setVaultKey = useCallback(async (passphrase) => {
|
||||
try {
|
||||
const oldKeyValue = await get('key')
|
||||
const vaultKey = await deriveKey(me.id, passphrase)
|
||||
const { data } = await getVaultEntries()
|
||||
|
||||
const entries = []
|
||||
for (const entry of data.getVaultEntries) {
|
||||
entry.value = await decryptData(oldKeyValue.key, entry.value)
|
||||
entries.push({ key: entry.key, value: await encryptData(vaultKey.key, entry.value) })
|
||||
}
|
||||
|
||||
await updateVaultKey({
|
||||
variables: { entries, hash: vaultKey.hash },
|
||||
onError: (error) => {
|
||||
const errorCode = error.graphQLErrors[0]?.extensions?.code
|
||||
if (errorCode === E_VAULT_KEY_EXISTS) {
|
||||
throw new Error('wrong passphrase')
|
||||
}
|
||||
toaster.danger(error.graphQLErrors[0].message)
|
||||
}
|
||||
})
|
||||
setKey(vaultKey)
|
||||
setKeyHash(vaultKey.hash)
|
||||
await set('key', vaultKey)
|
||||
} catch (e) {
|
||||
toaster.danger('error setting vault key ' + e.message)
|
||||
}
|
||||
}, [getVaultEntries, updateVaultKey, set, get, remove])
|
||||
|
||||
return [key, setVaultKey, clearVault]
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a key to be used for the vault encryption
|
||||
* @param {string | number} userId - the id of the user (used for salting)
|
||||
* @param {string} passphrase - the passphrase to derive the key from
|
||||
* @returns {Promise<{key: CryptoKey, hash: string, extractable: boolean}>} an un-extractable CryptoKey and its hash
|
||||
*/
|
||||
async function deriveKey (userId, passphrase) {
|
||||
const enc = new TextEncoder()
|
||||
|
||||
const keyMaterial = await window.crypto.subtle.importKey(
|
||||
'raw',
|
||||
enc.encode(passphrase),
|
||||
{ name: 'PBKDF2' },
|
||||
false,
|
||||
['deriveKey']
|
||||
)
|
||||
|
||||
const key = await window.crypto.subtle.deriveKey(
|
||||
{
|
||||
name: 'PBKDF2',
|
||||
salt: enc.encode(`stacker${userId}`),
|
||||
// 600,000 iterations is recommended by OWASP
|
||||
// see https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2
|
||||
iterations: 600_000,
|
||||
hash: 'SHA-256'
|
||||
},
|
||||
keyMaterial,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
true,
|
||||
['encrypt', 'decrypt']
|
||||
)
|
||||
|
||||
const rawKey = await window.crypto.subtle.exportKey('raw', key)
|
||||
const hash = toHex(await window.crypto.subtle.digest('SHA-256', rawKey))
|
||||
const unextractableKey = await window.crypto.subtle.importKey(
|
||||
'raw',
|
||||
rawKey,
|
||||
{ name: 'AES-GCM' },
|
||||
false,
|
||||
['encrypt', 'decrypt']
|
||||
)
|
||||
|
||||
return {
|
||||
key: unextractableKey,
|
||||
hash
|
||||
}
|
||||
}
|
|
@ -0,0 +1,64 @@
|
|||
import { useCallback } from 'react'
|
||||
import { useVaultConfigurator } from './use-vault-configurator'
|
||||
import { fromHex, toHex } from '@/lib/hex'
|
||||
|
||||
export default function useVault () {
|
||||
const { key } = useVaultConfigurator()
|
||||
|
||||
const encrypt = useCallback(async (value) => {
|
||||
if (!key) throw new Error('no vault key set')
|
||||
return await encryptData(key.key, value)
|
||||
}, [key])
|
||||
|
||||
const decrypt = useCallback(async (value) => {
|
||||
if (!key) throw new Error('no vault key set')
|
||||
return await decryptData(key.key, value)
|
||||
}, [key])
|
||||
|
||||
return { encrypt, decrypt, isActive: !!key }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
export 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
|
||||
*/
|
||||
export 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)
|
||||
}
|
|
@ -3,7 +3,7 @@ import styles from '@/styles/wallet.module.css'
|
|||
import Plug from '@/svgs/plug.svg'
|
||||
import Gear from '@/svgs/settings-5-fill.svg'
|
||||
import Link from 'next/link'
|
||||
import { Status } from 'wallets'
|
||||
import { Status } from '@/wallets/common'
|
||||
import DraggableIcon from '@/svgs/draggable.svg'
|
||||
|
||||
export default function WalletCard ({ wallet, draggable, onDragStart, onDragEnter, onDragEnd, onTouchStart, sourceIndex, targetIndex, index }) {
|
||||
|
|
|
@ -5,10 +5,10 @@ import { Button } from 'react-bootstrap'
|
|||
import { useToast } from './toast'
|
||||
import { useShowModal } from './modal'
|
||||
import { WALLET_LOGS } from '@/fragments/wallet'
|
||||
import { getWalletByType } from 'wallets'
|
||||
import { getWalletByType } from '@/wallets/common'
|
||||
import { gql, useLazyQuery, useMutation } from '@apollo/client'
|
||||
import { useMe } from './me'
|
||||
import useIndexedDB from './use-indexeddb'
|
||||
import useIndexedDB, { getDbName } from './use-indexeddb'
|
||||
import { SSR } from '@/lib/constants'
|
||||
|
||||
export function WalletLogs ({ wallet, embedded }) {
|
||||
|
@ -88,9 +88,11 @@ const INDICES = [
|
|||
|
||||
function useWalletLogDB () {
|
||||
const { me } = useMe()
|
||||
const dbName = `app:storage${me ? `:${me.id}` : ''}`
|
||||
const idbStoreName = 'wallet_logs'
|
||||
const { add, getPage, clear, error, notSupported } = useIndexedDB(dbName, idbStoreName, 1, INDICES)
|
||||
const { add, getPage, clear, error, notSupported } = useIndexedDB({
|
||||
dbName: getDbName(me?.id),
|
||||
storeName: 'wallet_logs',
|
||||
indices: INDICES
|
||||
})
|
||||
return { add, getPage, clear, error, notSupported }
|
||||
}
|
||||
|
||||
|
|
|
@ -427,9 +427,3 @@ export const USER_STATS = gql`
|
|||
}
|
||||
}
|
||||
}`
|
||||
|
||||
export const SET_VAULT_KEY_HASH = gql`
|
||||
mutation setVaultKeyHash($hash: String!) {
|
||||
setVaultKeyHash(hash: $hash)
|
||||
}
|
||||
`
|
||||
|
|
|
@ -6,65 +6,38 @@ export const VAULT_FIELDS = gql`
|
|||
key
|
||||
value
|
||||
createdAt
|
||||
updatedAt
|
||||
updatedAt
|
||||
}
|
||||
`
|
||||
|
||||
export const GET_ENTRY = gql`
|
||||
export const GET_VAULT_ENTRY = gql`
|
||||
${VAULT_FIELDS}
|
||||
query GetVaultEntry(
|
||||
$ownerId: ID!,
|
||||
$ownerType: String!,
|
||||
$key: String!
|
||||
) {
|
||||
getVaultEntry(ownerId: $ownerId, ownerType: $ownerType, key: $key) {
|
||||
getVaultEntry(key: $key) {
|
||||
...VaultFields
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const GET_ENTRIES = gql`
|
||||
export const GET_VAULT_ENTRIES = gql`
|
||||
${VAULT_FIELDS}
|
||||
query GetVaultEntries(
|
||||
$ownerId: ID!,
|
||||
$ownerType: String!
|
||||
) {
|
||||
getVaultEntries(ownerId: $ownerId, ownerType: $ownerType) {
|
||||
query GetVaultEntries {
|
||||
getVaultEntries {
|
||||
...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)
|
||||
export const UPDATE_VAULT_KEY = gql`
|
||||
mutation updateVaultKey($entries: [VaultEntryInput!]!, $hash: String!) {
|
||||
updateVaultKey(entries: $entries, hash: $hash)
|
||||
}
|
||||
`
|
||||
|
|
|
@ -188,19 +188,7 @@ export const WALLET_BY_TYPE = gql`
|
|||
|
||||
export const WALLETS = gql`
|
||||
query Wallets {
|
||||
wallets{
|
||||
id
|
||||
priority
|
||||
type,
|
||||
canSend,
|
||||
canReceive
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const BEST_WALLETS = gql`
|
||||
query BestWallets {
|
||||
wallets (includeSenders: true, includeReceivers: true, onlyEnabled: true, prioritySort: "asc") {
|
||||
wallets {
|
||||
id
|
||||
priority
|
||||
type
|
||||
|
@ -208,6 +196,10 @@ export const BEST_WALLETS = gql`
|
|||
canSend
|
||||
canReceive
|
||||
enabled
|
||||
vaultEntries {
|
||||
key
|
||||
value
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
@ -222,7 +214,7 @@ export const WALLET_LOGS = gql`
|
|||
wallet
|
||||
level
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
|
|
@ -1,62 +0,0 @@
|
|||
export function fieldToGqlArg (field) {
|
||||
let arg = `${field.name}: String`
|
||||
if (!field.optional) {
|
||||
arg += '!'
|
||||
}
|
||||
return arg
|
||||
}
|
||||
|
||||
// same as fieldToGqlArg, but makes the field always optional
|
||||
export function fieldToGqlArgOptional (field) {
|
||||
return `${field.name}: String`
|
||||
}
|
||||
|
||||
export function generateResolverName (walletField) {
|
||||
const capitalized = walletField[0].toUpperCase() + walletField.slice(1)
|
||||
return `upsert${capitalized}`
|
||||
}
|
||||
|
||||
export function generateTypeDefName (walletType) {
|
||||
const PascalCase = walletType.split('_').map(s => s[0].toUpperCase() + s.slice(1).toLowerCase()).join('')
|
||||
return `Wallet${PascalCase}`
|
||||
}
|
||||
|
||||
export function 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
|
||||
}
|
|
@ -22,7 +22,7 @@ import dynamic from 'next/dynamic'
|
|||
import { HasNewNotesProvider } from '@/components/use-has-new-notes'
|
||||
import { WebLnProvider } from '@/wallets/webln/client'
|
||||
import { AccountProvider } from '@/components/account'
|
||||
import { WalletProvider } from '@/wallets/index'
|
||||
import { WalletProvider } from '@/wallets/common'
|
||||
|
||||
const PWAPrompt = dynamic(() => import('react-ios-pwa-prompt'), { ssr: false })
|
||||
|
||||
|
|
|
@ -5,7 +5,7 @@ import { WalletSecurityBanner } from '@/components/banners'
|
|||
import { WalletLogs } from '@/components/wallet-logger'
|
||||
import { useToast } from '@/components/toast'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useWallet } from 'wallets'
|
||||
import { useWallet } from '@/wallets/common'
|
||||
import Info from '@/components/info'
|
||||
import Text from '@/components/text'
|
||||
import { AutowithdrawSettings } from '@/components/autowithdraw-shared'
|
||||
|
|
|
@ -2,7 +2,7 @@ import { getGetServerSideProps } from '@/api/ssrApollo'
|
|||
import Layout from '@/components/layout'
|
||||
import styles from '@/styles/wallet.module.css'
|
||||
import Link from 'next/link'
|
||||
import { useWallets, walletPrioritySort } from 'wallets'
|
||||
import { useWallets, walletPrioritySort } from '@/wallets/common'
|
||||
import { useState } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useIsClient } from '@/components/use-client'
|
||||
|
|
|
@ -1,28 +0,0 @@
|
|||
-- 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;
|
|
@ -1,15 +0,0 @@
|
|||
-- 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 true,
|
||||
ADD COLUMN "canSend" BOOLEAN NOT NULL DEFAULT false;
|
|
@ -0,0 +1,46 @@
|
|||
-- 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 true,
|
||||
ADD COLUMN "canSend" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "vaultKeyHash" TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "VaultEntry" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"key" VARCHAR(64) NOT NULL,
|
||||
"value" TEXT NOT NULL,
|
||||
"userId" INTEGER NOT NULL,
|
||||
"walletId" INTEGER,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "VaultEntry_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "VaultEntry_userId_idx" ON "VaultEntry"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "VaultEntry_walletId_idx" ON "VaultEntry"("walletId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "VaultEntry_userId_key_walletId_key" ON "VaultEntry"("userId", "key", "walletId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "VaultEntry" ADD CONSTRAINT "VaultEntry_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "VaultEntry" ADD CONSTRAINT "VaultEntry_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "Wallet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
@ -138,7 +138,7 @@ model User {
|
|||
oneDayReferrals OneDayReferral[] @relation("OneDayReferral_referrer")
|
||||
oneDayReferrees OneDayReferral[] @relation("OneDayReferral_referrees")
|
||||
vaultKeyHash String @default("")
|
||||
vaultEntries Vault[] @relation("VaultEntries")
|
||||
vaultEntries VaultEntry[] @relation("VaultEntries")
|
||||
|
||||
@@index([photoId])
|
||||
@@index([createdAt], map: "users.created_at_index")
|
||||
|
@ -187,14 +187,14 @@ enum WalletType {
|
|||
}
|
||||
|
||||
model Wallet {
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
userId Int
|
||||
label String?
|
||||
enabled Boolean @default(true)
|
||||
priority Int @default(0)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
userId Int
|
||||
label String?
|
||||
enabled Boolean @default(true)
|
||||
priority Int @default(0)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
canReceive Boolean @default(true)
|
||||
canSend Boolean @default(false)
|
||||
|
||||
|
@ -212,12 +212,30 @@ model Wallet {
|
|||
walletLNbits WalletLNbits?
|
||||
walletNWC WalletNWC?
|
||||
walletPhoenixd WalletPhoenixd?
|
||||
withdrawals Withdrawl[]
|
||||
InvoiceForward InvoiceForward[]
|
||||
|
||||
vaultEntries VaultEntry[] @relation("VaultEntries")
|
||||
withdrawals Withdrawl[]
|
||||
InvoiceForward InvoiceForward[]
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model VaultEntry {
|
||||
id Int @id @default(autoincrement())
|
||||
key String @db.VarChar(64)
|
||||
value String @db.Text
|
||||
userId Int
|
||||
walletId Int?
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade, name: "VaultEntries")
|
||||
wallet Wallet? @relation(fields: [walletId], references: [id], onDelete: Cascade, name: "VaultEntries")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
|
||||
@@unique([userId, key, walletId])
|
||||
@@index([userId])
|
||||
@@index([walletId])
|
||||
}
|
||||
|
||||
model WalletLog {
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
@ -1120,22 +1138,6 @@ model Reminder {
|
|||
@@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 {
|
||||
POST
|
||||
COMMENT
|
||||
|
|
|
@ -0,0 +1,47 @@
|
|||
import walletDefs from 'wallets/client'
|
||||
|
||||
export const Status = {
|
||||
Initialized: 'Initialized',
|
||||
Enabled: 'Enabled',
|
||||
Locked: 'Locked',
|
||||
Error: 'Error'
|
||||
}
|
||||
|
||||
export function getWalletByName (name) {
|
||||
return walletDefs.find(def => def.name === name)
|
||||
}
|
||||
|
||||
export function getWalletByType (type) {
|
||||
return walletDefs.find(def => def.walletType === type)
|
||||
}
|
||||
|
||||
export function getStorageKey (name, me) {
|
||||
let storageKey = `wallet:${name}`
|
||||
|
||||
// WebLN has no credentials we need to scope to users
|
||||
// so we can use the same storage key for all users
|
||||
if (me && name !== 'webln') {
|
||||
storageKey = `${storageKey}:${me.id}`
|
||||
}
|
||||
|
||||
return storageKey
|
||||
}
|
||||
|
||||
export function walletPrioritySort (w1, w2) {
|
||||
const delta = w1.priority - w2.priority
|
||||
// delta is NaN if either priority is undefined
|
||||
if (!Number.isNaN(delta) && delta !== 0) return delta
|
||||
|
||||
// if one wallet has a priority but the other one doesn't, the one with the priority comes first
|
||||
if (w1.priority !== undefined && w2.priority === undefined) return -1
|
||||
if (w1.priority === undefined && w2.priority !== undefined) return 1
|
||||
|
||||
// both wallets have no priority set, falling back to other methods
|
||||
|
||||
// if both wallets have an id, use that as tie breaker
|
||||
// since that's the order in which autowithdrawals are attempted
|
||||
if (w1.config?.id && w2.config?.id) return Number(w1.config.id) - Number(w2.config.id)
|
||||
|
||||
// else we will use the card title as tie breaker
|
||||
return w1.card.title < w2.card.title ? -1 : 1
|
||||
}
|
|
@ -0,0 +1,164 @@
|
|||
import { useMe } from '@/components/me'
|
||||
import useVault from '@/components/use-vault'
|
||||
import { useCallback } from 'react'
|
||||
import { getStorageKey } from './common'
|
||||
import { useMutation } from '@apollo/client'
|
||||
import { generateMutation } from './graphql'
|
||||
import { REMOVE_WALLET } from '@/fragments/wallet'
|
||||
import { walletValidate } from '@/lib/validate'
|
||||
import { useWalletLogger } from '@/components/wallet-logger'
|
||||
|
||||
export function useWalletConfigurator (wallet) {
|
||||
const { me } = useMe()
|
||||
const { encrypt, isActive } = useVault()
|
||||
const { logger } = useWalletLogger(wallet.def)
|
||||
const [upsertWallet] = useMutation(generateMutation(wallet.def))
|
||||
const [removeWallet] = useMutation(REMOVE_WALLET)
|
||||
|
||||
const _saveToServer = useCallback(async (serverConfig, clientConfig) => {
|
||||
const vaultEntries = []
|
||||
if (clientConfig) {
|
||||
for (const [key, value] of Object.entries(clientConfig)) {
|
||||
vaultEntries.push({ key, value: encrypt(value) })
|
||||
}
|
||||
}
|
||||
await upsertWallet({ variables: { ...serverConfig, vaultEntries } })
|
||||
}, [encrypt, isActive])
|
||||
|
||||
const _saveToLocal = useCallback(async (newConfig) => {
|
||||
window.localStorage.setItem(getStorageKey(wallet.name, me), JSON.stringify(newConfig))
|
||||
}, [me, wallet.name])
|
||||
|
||||
const save = useCallback(async (newConfig, validate = true) => {
|
||||
let clientConfig = extractClientConfig(wallet.def.fields, newConfig)
|
||||
let serverConfig = extractServerConfig(wallet.def.fields, newConfig)
|
||||
|
||||
if (validate) {
|
||||
if (clientConfig) {
|
||||
let transformedConfig = await walletValidate(wallet, clientConfig)
|
||||
if (transformedConfig) {
|
||||
clientConfig = Object.assign(clientConfig, transformedConfig)
|
||||
}
|
||||
transformedConfig = await wallet.def.testSendPayment(clientConfig, { me, logger })
|
||||
if (transformedConfig) {
|
||||
clientConfig = Object.assign(clientConfig, transformedConfig)
|
||||
}
|
||||
}
|
||||
|
||||
if (serverConfig) {
|
||||
const transformedConfig = await walletValidate(wallet, serverConfig)
|
||||
if (transformedConfig) {
|
||||
serverConfig = Object.assign(serverConfig, transformedConfig)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// if vault is active, encrypt and send to server regardless of wallet type
|
||||
if (isActive) {
|
||||
await _saveToServer(serverConfig, clientConfig)
|
||||
} else {
|
||||
if (clientConfig) {
|
||||
await _saveToLocal(clientConfig)
|
||||
}
|
||||
if (serverConfig) {
|
||||
await _saveToServer(serverConfig)
|
||||
}
|
||||
}
|
||||
}, [wallet.def, encrypt, isActive])
|
||||
|
||||
const _detachFromServer = useCallback(async () => {
|
||||
await removeWallet({ variables: { id: wallet.config.id } })
|
||||
}, [wallet.config.id])
|
||||
|
||||
const _detachFromLocal = useCallback(async () => {
|
||||
// if vault is not active and has a client config, delete from local storage
|
||||
window.localStorage.removeItem(getStorageKey(wallet.name, me))
|
||||
}, [me, wallet.name])
|
||||
|
||||
const detach = useCallback(async () => {
|
||||
if (isActive) {
|
||||
await _detachFromServer()
|
||||
} else {
|
||||
if (wallet.config.id) {
|
||||
await _detachFromServer()
|
||||
}
|
||||
|
||||
await _detachFromLocal()
|
||||
}
|
||||
}, [isActive, _detachFromServer, _detachFromLocal])
|
||||
|
||||
return [save, detach]
|
||||
}
|
||||
|
||||
function extractConfig (fields, config, client, includeMeta = true) {
|
||||
return Object.entries(config).reduce((acc, [key, value]) => {
|
||||
const field = fields.find(({ name }) => name === key)
|
||||
|
||||
// filter server config which isn't specified as wallet fields
|
||||
// (we allow autowithdraw members to pass validation)
|
||||
if (client && key === 'id') return acc
|
||||
|
||||
// field might not exist because config.enabled doesn't map to a wallet field
|
||||
if ((!field && includeMeta) || (field && (client ? isClientField(field) : isServerField(field)))) {
|
||||
return {
|
||||
...acc,
|
||||
[key]: value
|
||||
}
|
||||
} else {
|
||||
return acc
|
||||
}
|
||||
}, {})
|
||||
}
|
||||
|
||||
function extractClientConfig (fields, config) {
|
||||
return extractConfig(fields, config, true, false)
|
||||
}
|
||||
|
||||
function extractServerConfig (fields, config) {
|
||||
return extractConfig(fields, config, false, true)
|
||||
}
|
||||
|
||||
export function isServerField (f) {
|
||||
return f.serverOnly || !f.clientOnly
|
||||
}
|
||||
|
||||
export function isClientField (f) {
|
||||
return f.clientOnly || !f.serverOnly
|
||||
}
|
||||
|
||||
function checkFields ({ fields, config }) {
|
||||
// a wallet is configured if all of its required fields are set
|
||||
let val = fields.every(f => {
|
||||
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
|
||||
}
|
||||
|
||||
export function isConfigured (wallet) {
|
||||
return isSendConfigured(wallet) || isReceiveConfigured(wallet)
|
||||
}
|
||||
|
||||
function isSendConfigured (wallet) {
|
||||
const fields = wallet.def.fields.filter(isClientField)
|
||||
return checkFields({ fields, config: wallet.config })
|
||||
}
|
||||
|
||||
function isReceiveConfigured (wallet) {
|
||||
const fields = wallet.def.fields.filter(isServerField)
|
||||
return checkFields({ fields, config: wallet.config })
|
||||
}
|
||||
|
||||
export function canSend (wallet) {
|
||||
return !!wallet.def.sendPayment && isSendConfigured(wallet)
|
||||
}
|
||||
|
||||
export function canReceive (wallet) {
|
||||
return !wallet.def.clientOnly && isReceiveConfigured(wallet)
|
||||
}
|
|
@ -0,0 +1,59 @@
|
|||
import gql from 'graphql-tag'
|
||||
import { isServerField } from './config'
|
||||
|
||||
export function fieldToGqlArg (field) {
|
||||
let arg = `${field.name}: String`
|
||||
if (!field.optional) {
|
||||
arg += '!'
|
||||
}
|
||||
return arg
|
||||
}
|
||||
|
||||
// same as fieldToGqlArg, but makes the field always optional
|
||||
export function fieldToGqlArgOptional (field) {
|
||||
return `${field.name}: String`
|
||||
}
|
||||
|
||||
export function generateResolverName (walletField) {
|
||||
const capitalized = walletField[0].toUpperCase() + walletField.slice(1)
|
||||
return `upsert${capitalized}`
|
||||
}
|
||||
|
||||
export function generateTypeDefName (walletType) {
|
||||
const PascalCase = walletType.split('_').map(s => s[0].toUpperCase() + s.slice(1).toLowerCase()).join('')
|
||||
return `Wallet${PascalCase}`
|
||||
}
|
||||
|
||||
export function generateMutation (wallet) {
|
||||
const resolverName = generateResolverName(wallet.walletField)
|
||||
|
||||
let headerArgs = '$id: ID, '
|
||||
headerArgs += wallet.fields
|
||||
.filter(isServerField)
|
||||
.map(f => {
|
||||
const arg = `$${f.name}: String`
|
||||
// required fields are checked server-side
|
||||
// if (!f.optional) {
|
||||
// arg += '!'
|
||||
// }
|
||||
return arg
|
||||
}).join(', ')
|
||||
headerArgs += ', $settings: AutowithdrawSettings!, $priorityOnly: Boolean, $canSend: Boolean!, $canReceive: Boolean!'
|
||||
|
||||
let inputArgs = 'id: $id, '
|
||||
inputArgs += wallet.fields
|
||||
.filter(isServerField)
|
||||
.map(f => `${f.name}: $${f.name}`).join(', ')
|
||||
inputArgs += ', settings: $settings, priorityOnly: $priorityOnly, canSend: $canSend, canReceive: $canReceive,'
|
||||
|
||||
return gql`mutation ${resolverName}(${headerArgs}) {
|
||||
${resolverName}(${inputArgs}) {
|
||||
id,
|
||||
type,
|
||||
enabled,
|
||||
priority,
|
||||
canReceive,
|
||||
canSend
|
||||
}
|
||||
}`
|
||||
}
|
599
wallets/index.js
599
wallets/index.js
|
@ -1,532 +1,123 @@
|
|||
import { createContext, useContext, useCallback, useState, useEffect, useRef } from 'react'
|
||||
import { useMe } from '@/components/me'
|
||||
import { openVault } from '@/components/use-vault'
|
||||
import { WALLETS } from '@/fragments/wallet'
|
||||
import { NORMAL_POLL_INTERVAL, SSR } from '@/lib/constants'
|
||||
import { useQuery } from '@apollo/client'
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import { getStorageKey, getWalletByType } from './common'
|
||||
import useVault from '@/components/use-vault'
|
||||
import { useWalletLogger } from '@/components/wallet-logger'
|
||||
import { bolt11Tags } from '@/lib/bolt11'
|
||||
|
||||
import walletDefs from 'wallets/client'
|
||||
import { gql, useApolloClient, useMutation, useQuery } from '@apollo/client'
|
||||
import { REMOVE_WALLET, WALLET_BY_TYPE, BEST_WALLETS } from '@/fragments/wallet'
|
||||
import { autowithdrawInitial } from '@/components/autowithdraw-shared'
|
||||
import { useShowModal } from '@/components/modal'
|
||||
import { useToast } from '../components/toast'
|
||||
import { generateResolverName, isConfigured, isClientField, isServerField } from '@/lib/wallet'
|
||||
import { walletValidate } from '@/lib/validate'
|
||||
import { SSR, FAST_POLL_INTERVAL as POLL_INTERVAL } from '@/lib/constants'
|
||||
import { canSend } from './config'
|
||||
|
||||
export const Status = {
|
||||
Initialized: 'Initialized',
|
||||
Enabled: 'Enabled',
|
||||
Locked: 'Locked',
|
||||
Error: 'Error'
|
||||
}
|
||||
|
||||
const WalletContext = createContext({
|
||||
wallets: [],
|
||||
sendWallets: []
|
||||
const WalletsContext = createContext({
|
||||
wallets: []
|
||||
})
|
||||
|
||||
export function useWallet (name) {
|
||||
const context = useContext(WalletContext)
|
||||
const bestSendWalletList = context.sendWallets
|
||||
if (!name) {
|
||||
// find best wallet in list
|
||||
const highestWalletDef = bestSendWalletList?.map(w => getWalletByType(w.type))
|
||||
.filter(w => !w.isAvailable || w.isAvailable())
|
||||
name = highestWalletDef?.[0]?.name
|
||||
}
|
||||
const wallet = context.wallets.find(w => w.def.name === name)
|
||||
return wallet
|
||||
function useLocalWallets () {
|
||||
const { me } = useMe()
|
||||
const [wallets, setWallets] = useState([])
|
||||
|
||||
const loadWallets = useCallback(() => {
|
||||
// form wallets into a list of { config, def }
|
||||
const wallets = walletDefs.map(w => {
|
||||
try {
|
||||
const config = window.localStorage.getItem(getStorageKey(w.name, me))
|
||||
return { def: w, config: JSON.parse(config) }
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}).filter(Boolean)
|
||||
setWallets(wallets)
|
||||
}, [me, setWallets])
|
||||
|
||||
// watch for changes to local storage
|
||||
useEffect(() => {
|
||||
loadWallets()
|
||||
// reload wallets if local storage to wallet changes
|
||||
const handler = (event) => {
|
||||
if (event.key.startsWith('wallet:')) {
|
||||
loadWallets()
|
||||
}
|
||||
}
|
||||
window.addEventListener('storage', handler)
|
||||
return () => window.removeEventListener('storage', handler)
|
||||
}, [loadWallets])
|
||||
|
||||
return wallets
|
||||
}
|
||||
|
||||
function useWalletInner (name) {
|
||||
export function WalletsProvider ({ children }) {
|
||||
const { me } = useMe()
|
||||
const showModal = useShowModal()
|
||||
const toaster = useToast()
|
||||
const [disableFreebies] = useMutation(gql`mutation { disableFreebies }`)
|
||||
const { decrypt } = useVault()
|
||||
const localWallets = useLocalWallets()
|
||||
|
||||
const walletDef = getWalletByName(name)
|
||||
// TODO: instead of polling, this should only be called when the vault key is updated
|
||||
// or a denormalized field on the user 'vaultUpdatedAt' is changed
|
||||
const { data } = useQuery(WALLETS, {
|
||||
pollInterval: NORMAL_POLL_INTERVAL,
|
||||
nextFetchPolicy: 'cache-and-network',
|
||||
skip: !me?.id || SSR
|
||||
})
|
||||
|
||||
const { logger, deleteLogs } = useWalletLogger(walletDef)
|
||||
const [config, saveConfig, clearConfig, refreshConfig] = useConfig(walletDef)
|
||||
const available = (!walletDef?.isAvailable || walletDef?.isAvailable())
|
||||
const wallets = useMemo(() => {
|
||||
// form wallets into a list of { config, def }
|
||||
const wallets = data?.wallets?.map(w => {
|
||||
const def = getWalletByType(w.type)
|
||||
const { vaultEntries, ...config } = w
|
||||
for (const { key, value } of vaultEntries) {
|
||||
config[key] = decrypt(value)
|
||||
}
|
||||
|
||||
const status = config?.enabled && available && (config.canSend || config.canReceive) ? Status.Enabled : Status.Initialized
|
||||
const enabled = status === Status.Enabled
|
||||
const priority = config?.priority
|
||||
const hasConfig = walletDef?.fields?.length > 0
|
||||
const _isConfigured = useCallback(() => {
|
||||
return isConfigured({ ...walletDef, config })
|
||||
}, [walletDef, config])
|
||||
return { config, def }
|
||||
})
|
||||
|
||||
const enablePayments = useCallback((updatedConfig) => {
|
||||
saveConfig({ ...(updatedConfig || config), enabled: true }, { skipTests: true })
|
||||
logger.ok('payments enabled')
|
||||
disableFreebies().catch(console.error)
|
||||
}, [config])
|
||||
// merge wallets on name
|
||||
const merged = {}
|
||||
for (const wallet of [...localWallets, ...wallets]) {
|
||||
merged[wallet.def.name] = { ...merged[wallet.def.name], ...wallet }
|
||||
}
|
||||
return Object.values(merged)
|
||||
}, [data?.wallets, localWallets])
|
||||
|
||||
const disablePayments = useCallback((updatedConfig) => {
|
||||
saveConfig({ ...(updatedConfig || config), enabled: false }, { skipTests: true })
|
||||
logger.info('payments disabled')
|
||||
}, [config])
|
||||
return (
|
||||
<WalletsContext.Provider value={wallets}>
|
||||
{children}
|
||||
</WalletsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useWallets () {
|
||||
return useContext(WalletsContext)
|
||||
}
|
||||
|
||||
export function useWallet (name) {
|
||||
const wallets = useWallets()
|
||||
|
||||
const wallet = useMemo(() => {
|
||||
if (name) {
|
||||
return wallets.find(w => w.def.name === name)
|
||||
}
|
||||
|
||||
return wallets
|
||||
.filter(w => !w.def.isAvailable || w.def.isAvailable())
|
||||
.filter(w => w.config.enabled && canSend(w))[0]
|
||||
}, [wallets, name])
|
||||
|
||||
const { logger } = useWalletLogger(wallet.def)
|
||||
|
||||
const sendPayment = useCallback(async (bolt11) => {
|
||||
const hash = bolt11Tags(bolt11).payment_hash
|
||||
logger.info('sending payment:', `payment_hash=${hash}`)
|
||||
try {
|
||||
const preimage = await walletDef.sendPayment(bolt11, config, { me, logger, status, showModal })
|
||||
const preimage = await wallet.def.sendPayment(bolt11, wallet.config, { logger })
|
||||
logger.ok('payment successful:', `payment_hash=${hash}`, `preimage=${preimage}`)
|
||||
} catch (err) {
|
||||
const message = err.message || err.toString?.()
|
||||
logger.error('payment failed:', `payment_hash=${hash}`, message)
|
||||
throw err
|
||||
}
|
||||
}, [me, walletDef, config])
|
||||
}, [wallet, logger])
|
||||
|
||||
const setPriority = useCallback(async (priority) => {
|
||||
if (_isConfigured() && priority !== config.priority) {
|
||||
try {
|
||||
await saveConfig({ ...config, priority }, { logger, skipTests: true })
|
||||
} catch (err) {
|
||||
toaster.danger(`failed to change priority of ${walletDef.name} wallet: ${err.message}`)
|
||||
}
|
||||
}
|
||||
}, [walletDef, config])
|
||||
|
||||
const save = useCallback(async (newConfig) => {
|
||||
await saveConfig(newConfig, { logger })
|
||||
const available = (!walletDef.isAvailable || walletDef.isAvailable())
|
||||
logger.ok(_isConfigured() ? 'payment details updated' : 'wallet attached for payments')
|
||||
if (newConfig.enabled && available) logger.ok('payments enabled')
|
||||
else logger.ok('payments disabled')
|
||||
}, [saveConfig, me])
|
||||
|
||||
// delete is a reserved keyword
|
||||
const delete_ = useCallback(async (options) => {
|
||||
try {
|
||||
logger.ok('wallet detached for payments')
|
||||
await clearConfig({ logger, ...options })
|
||||
} catch (err) {
|
||||
const message = err.message || err.toString?.()
|
||||
logger.error(message)
|
||||
throw err
|
||||
}
|
||||
}, [clearConfig])
|
||||
|
||||
const deleteLogs_ = useCallback(async (options) => {
|
||||
// first argument is to override the wallet
|
||||
return await deleteLogs(options)
|
||||
}, [deleteLogs])
|
||||
|
||||
if (!walletDef) return null
|
||||
|
||||
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
|
||||
wallet.available = available
|
||||
wallet.priority = priority
|
||||
wallet.logger = logger
|
||||
wallet.sendPayment = sendPayment
|
||||
wallet.def = walletDef
|
||||
wallet.refresh = () => {
|
||||
return refreshConfig()
|
||||
}
|
||||
return wallet
|
||||
}
|
||||
|
||||
function extractConfig (fields, config, client, includeMeta = true) {
|
||||
return Object.entries(config).reduce((acc, [key, value]) => {
|
||||
const field = fields.find(({ name }) => name === key)
|
||||
|
||||
// filter server config which isn't specified as wallet fields
|
||||
// (we allow autowithdraw members to pass validation)
|
||||
if (client && key === 'id') return acc
|
||||
|
||||
// field might not exist because config.enabled doesn't map to a wallet field
|
||||
if ((!field && includeMeta) || (field && (client ? isClientField(field) : isServerField(field)))) {
|
||||
return {
|
||||
...acc,
|
||||
[key]: value
|
||||
}
|
||||
} else {
|
||||
return acc
|
||||
}
|
||||
}, {})
|
||||
}
|
||||
|
||||
function extractClientConfig (fields, config) {
|
||||
return extractConfig(fields, config, true, false)
|
||||
}
|
||||
|
||||
function extractServerConfig (fields, config) {
|
||||
return extractConfig(fields, config, false, true)
|
||||
}
|
||||
|
||||
function useConfig (walletDef) {
|
||||
const client = useApolloClient()
|
||||
const { me } = useMe()
|
||||
const toaster = useToast()
|
||||
const autowithdrawSettings = autowithdrawInitial({ me })
|
||||
const clientVault = useRef(null)
|
||||
|
||||
const [config, innerSetConfig] = useState({})
|
||||
const [currentWallet, innerSetCurrentWallet] = useState(null)
|
||||
|
||||
const canSend = !!walletDef?.sendPayment
|
||||
const canReceive = !walletDef?.clientOnly
|
||||
|
||||
const queryServerWallet = useCallback(async () => {
|
||||
const wallet = await client.query({
|
||||
query: WALLET_BY_TYPE,
|
||||
variables: { type: walletDef.walletType },
|
||||
fetchPolicy: 'network-only'
|
||||
})
|
||||
return wallet?.data?.walletByType
|
||||
}, [walletDef, client])
|
||||
|
||||
const refreshConfig = useCallback(async () => {
|
||||
if (!me?.id) return
|
||||
if (walletDef) {
|
||||
let newConfig = {}
|
||||
newConfig = {
|
||||
...autowithdrawSettings
|
||||
}
|
||||
|
||||
// fetch server config
|
||||
const serverConfig = await queryServerWallet()
|
||||
|
||||
if (serverConfig) {
|
||||
newConfig = {
|
||||
...newConfig,
|
||||
id: serverConfig.id,
|
||||
priority: serverConfig.priority,
|
||||
enabled: serverConfig.enabled
|
||||
}
|
||||
if (serverConfig.wallet) {
|
||||
newConfig = {
|
||||
...newConfig,
|
||||
...serverConfig.wallet
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fetch client config
|
||||
let clientConfig = {}
|
||||
if (serverConfig) {
|
||||
if (clientVault.current) clientVault.current.close()
|
||||
const newClientVault = openVault(client, me, serverConfig)
|
||||
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)
|
||||
}
|
||||
}, [walletDef, me])
|
||||
|
||||
useEffect(() => {
|
||||
refreshConfig()
|
||||
}, [walletDef, me])
|
||||
|
||||
const saveConfig = useCallback(async (newConfig, { logger, skipTests }) => {
|
||||
const serverConfig = await queryServerWallet()
|
||||
const priorityOnly = skipTests
|
||||
try {
|
||||
// gather configs
|
||||
|
||||
let newClientConfig = extractClientConfig(walletDef.fields, newConfig)
|
||||
try {
|
||||
const transformedConfig = await walletValidate(walletDef, newClientConfig)
|
||||
if (transformedConfig) {
|
||||
newClientConfig = Object.assign(newClientConfig, transformedConfig)
|
||||
}
|
||||
} catch (e) {
|
||||
newClientConfig = {}
|
||||
}
|
||||
|
||||
let newServerConfig = extractServerConfig(walletDef.fields, newConfig)
|
||||
try {
|
||||
const transformedConfig = await walletValidate(walletDef, newServerConfig)
|
||||
if (transformedConfig) {
|
||||
newServerConfig = Object.assign(newServerConfig, transformedConfig)
|
||||
}
|
||||
} catch (e) {
|
||||
newServerConfig = {}
|
||||
}
|
||||
|
||||
// check if it misses send or receive configs
|
||||
const isReadyToSend = canSend && isConfigured({ fields: walletDef.fields, config: newConfig, clientOnly: true })
|
||||
const isReadyToReceive = canReceive && isConfigured({ fields: walletDef.fields, config: newConfig, serverOnly: true })
|
||||
const { autoWithdrawThreshold, autoWithdrawMaxFeePercent, autoWithdrawMaxFeeTotal, priority, enabled } = newConfig
|
||||
|
||||
// console.log('New client config', newClientConfig)
|
||||
// console.log('New server config', newServerConfig)
|
||||
// console.log('Sender', isReadyToSend, 'Receiver', isReadyToReceive, 'enabled', enabled, autoWithdrawThreshold, autoWithdrawMaxFeePercent, priority)
|
||||
|
||||
// client test
|
||||
if (!skipTests && isReadyToSend && enabled) {
|
||||
try {
|
||||
// XXX: testSendPayment can return a new config (e.g. lnc)
|
||||
const newerConfig = await walletDef.testSendPayment?.(newClientConfig, { me, logger })
|
||||
if (newerConfig) {
|
||||
newClientConfig = Object.assign(newClientConfig, newerConfig)
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(err.message)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// set server config (will create wallet if it doesn't exist) (it is also testing receive config)
|
||||
if (!isReadyToSend && !isReadyToReceive) throw new Error('wallet should be configured to send or receive payments')
|
||||
|
||||
const mutation = generateMutation(walletDef)
|
||||
const variables = {
|
||||
...newServerConfig,
|
||||
id: serverConfig?.id,
|
||||
settings: {
|
||||
autoWithdrawThreshold: Number(autoWithdrawThreshold == null ? autowithdrawSettings.autoWithdrawThreshold : autoWithdrawThreshold),
|
||||
autoWithdrawMaxFeePercent: Number(autoWithdrawMaxFeePercent == null ? autowithdrawSettings.autoWithdrawMaxFeePercent : autoWithdrawMaxFeePercent),
|
||||
autoWithdrawMaxFeeTotal: Number(autoWithdrawMaxFeeTotal == null ? autowithdrawSettings.autoWithdrawMaxFeeTotal : autoWithdrawMaxFeeTotal),
|
||||
priority,
|
||||
enabled
|
||||
},
|
||||
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 {
|
||||
client.refetchQueries({ include: ['WalletLogs'] })
|
||||
await refreshConfig()
|
||||
}
|
||||
}, [config, currentWallet, canSend, canReceive])
|
||||
|
||||
const clearConfig = useCallback(async ({ logger, clientOnly, ...options }) => {
|
||||
// only remove wallet if there is a wallet to remove
|
||||
if (!currentWallet?.id) return
|
||||
try {
|
||||
const clearVault = openVault(client, me, currentWallet, {})
|
||||
try {
|
||||
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 {
|
||||
client.refetchQueries({ include: ['WalletLogs'] })
|
||||
await refreshConfig()
|
||||
}
|
||||
}, [config, currentWallet])
|
||||
|
||||
return [config, saveConfig, clearConfig, refreshConfig]
|
||||
}
|
||||
|
||||
function generateMutation (wallet) {
|
||||
const resolverName = generateResolverName(wallet.walletField)
|
||||
|
||||
let headerArgs = '$id: ID, '
|
||||
headerArgs += wallet.fields
|
||||
.filter(isServerField)
|
||||
.map(f => {
|
||||
const arg = `$${f.name}: String`
|
||||
// required fields are checked server-side
|
||||
// if (!f.optional) {
|
||||
// arg += '!'
|
||||
// }
|
||||
return arg
|
||||
}).join(', ')
|
||||
headerArgs += ', $settings: AutowithdrawSettings!, $priorityOnly: Boolean, $canSend: Boolean!, $canReceive: Boolean!'
|
||||
|
||||
let inputArgs = 'id: $id, '
|
||||
inputArgs += wallet.fields
|
||||
.filter(isServerField)
|
||||
.map(f => `${f.name}: $${f.name}`).join(', ')
|
||||
inputArgs += ', settings: $settings, priorityOnly: $priorityOnly, canSend: $canSend, canReceive: $canReceive,'
|
||||
|
||||
return gql`mutation ${resolverName}(${headerArgs}) {
|
||||
${resolverName}(${inputArgs}) {
|
||||
id,
|
||||
type,
|
||||
enabled,
|
||||
priority,
|
||||
canReceive,
|
||||
canSend
|
||||
}
|
||||
}`
|
||||
}
|
||||
|
||||
export function getWalletByName (name) {
|
||||
return walletDefs.find(def => def.name === name)
|
||||
}
|
||||
|
||||
export function getWalletByType (type) {
|
||||
return walletDefs.find(def => def.walletType === type)
|
||||
}
|
||||
|
||||
export function walletPrioritySort (w1, w2) {
|
||||
const delta = w1.priority - w2.priority
|
||||
// delta is NaN if either priority is undefined
|
||||
if (!Number.isNaN(delta) && delta !== 0) return delta
|
||||
|
||||
// if one wallet has a priority but the other one doesn't, the one with the priority comes first
|
||||
if (w1.priority !== undefined && w2.priority === undefined) return -1
|
||||
if (w1.priority === undefined && w2.priority !== undefined) return 1
|
||||
|
||||
// both wallets have no priority set, falling back to other methods
|
||||
|
||||
// if both wallets have an id, use that as tie breaker
|
||||
// since that's the order in which autowithdrawals are attempted
|
||||
if (w1.config?.id && w2.config?.id) return Number(w1.config.id) - Number(w2.config.id)
|
||||
|
||||
// else we will use the card title as tie breaker
|
||||
return w1.card.title < w2.card.title ? -1 : 1
|
||||
}
|
||||
|
||||
export function useWallets () {
|
||||
const { wallets } = useContext(WalletContext)
|
||||
const resetClient = useCallback(async (wallet) => {
|
||||
for (const w of wallets) {
|
||||
if (w.canSend) {
|
||||
await w.delete({ clientOnly: true, onlyFromLocalStorage: true })
|
||||
}
|
||||
await w.deleteLogs({ clientOnly: true })
|
||||
}
|
||||
}, [wallets])
|
||||
return { wallets, resetClient }
|
||||
}
|
||||
|
||||
export function WalletProvider ({ children }) {
|
||||
const { me } = useMe()
|
||||
const migrationRan = useRef(false)
|
||||
const migratableKeys = !migrationRan.current && !SSR ? Object.keys(window.localStorage).filter(k => k.startsWith('wallet:')) : undefined
|
||||
|
||||
const walletList = walletDefs.map(def => useWalletInner(def.name)).filter(w => w)
|
||||
const { data: bestWalletList } = useQuery(BEST_WALLETS, {
|
||||
pollInterval: POLL_INTERVAL,
|
||||
nextFetchPolicy: 'cache-and-network',
|
||||
skip: !me?.id
|
||||
})
|
||||
|
||||
const processSendWallets = (bestWalletData) => {
|
||||
const clientSideSorting = false // sorting is now done on the server
|
||||
let wallets = (bestWalletData?.wallets ?? []).filter(w => w.canSend)
|
||||
if (clientSideSorting) wallets = wallets.sort(walletPrioritySort)
|
||||
return wallets
|
||||
}
|
||||
|
||||
const wallets = walletList.sort(walletPrioritySort)
|
||||
const [bestSendWallets, innerSetBestSendWallets] = useState(() => processSendWallets(bestWalletList))
|
||||
|
||||
useEffect(() => {
|
||||
innerSetBestSendWallets(processSendWallets(bestWalletList))
|
||||
for (const wallet of wallets) {
|
||||
wallet.refresh()
|
||||
}
|
||||
}, [bestWalletList])
|
||||
|
||||
// migration
|
||||
useEffect(() => {
|
||||
if (SSR || !me?.id || !wallets.length) return
|
||||
if (migrationRan.current) return
|
||||
migrationRan.current = true
|
||||
if (!migratableKeys?.length) {
|
||||
console.log('wallet migrator: nothing to migrate', migratableKeys)
|
||||
return
|
||||
}
|
||||
const userId = me.id
|
||||
// List all local storage keys related to wallet settings
|
||||
const userKeys = migratableKeys.filter(k => k.endsWith(`:${userId}`))
|
||||
;(async () => {
|
||||
for (const key of userKeys) {
|
||||
try {
|
||||
const walletType = key.substring('wallet:'.length, key.length - userId.length - 1)
|
||||
const walletConfig = JSON.parse(window.localStorage.getItem(key))
|
||||
const wallet = wallets.find(w => w.def.name === walletType)
|
||||
if (wallet) {
|
||||
console.log('Migrating', walletType, walletConfig)
|
||||
await wallet.save(walletConfig)
|
||||
window.localStorage.removeItem(key)
|
||||
} else {
|
||||
console.warn('No wallet found for', walletType, wallets)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to migrate wallet', key, e)
|
||||
}
|
||||
}
|
||||
})()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<WalletContext.Provider value={{ wallets, sendWallets: bestSendWallets }}>
|
||||
{children}
|
||||
</WalletContext.Provider>
|
||||
)
|
||||
return { ...wallet, sendPayment }
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue