user vault and server side client wallets

This commit is contained in:
Riccardo Balbo 2024-10-14 17:49:06 +02:00 committed by k00b
parent 7a942881ed
commit b70dbeb6d6
49 changed files with 2261 additions and 446 deletions

View File

@ -19,6 +19,7 @@ import chainFee from './chainFee'
import { GraphQLScalarType, Kind } from 'graphql'
import { createIntScalar } from 'graphql-scalar'
import paidAction from './paidAction'
import vault from './vault'
const date = new GraphQLScalarType({
name: 'Date',
@ -55,4 +56,4 @@ const limit = createIntScalar({
export default [user, item, message, wallet, lnurl, notifications, invite, sub,
upload, search, growth, rewards, referrals, price, admin, blockHeight, chainFee,
{ JSONObject }, { Date: date }, { Limit: limit }, paidAction]
{ JSONObject }, { Date: date }, { Limit: limit }, paidAction, vault]

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

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

View File

@ -19,7 +19,7 @@ import assertApiKeyNotPermitted from './apiKey'
import { bolt11Tags } from '@/lib/bolt11'
import { finalizeHodlInvoice } from 'worker/wallet'
import walletDefs from 'wallets/server'
import { generateResolverName, generateTypeDefName } from '@/lib/wallet'
import { generateResolverName, generateTypeDefName, isConfigured } from '@/lib/wallet'
import { lnAddrOptions } from '@/lib/lnurl'
import { GqlAuthenticationError, GqlAuthorizationError, GqlInputError } from '@/lib/error'
import { getNodeSockets, getOurPubkey } from '../lnd'
@ -29,10 +29,18 @@ function injectResolvers (resolvers) {
for (const w of walletDefs) {
const resolverName = generateResolverName(w.walletField)
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 }) => {
// allow transformation of the data on validation (this is optional ... won't do anything if not implemented)
if (!priorityOnly) {
if (!priorityOnly && canReceive) {
// check if the required fields are set
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 })
if (validData) {
Object.keys(validData).filter(key => key in data).forEach(key => { data[key] = validData[key] })
@ -41,9 +49,19 @@ function injectResolvers (resolvers) {
}
return await upsertWallet({
wallet: { field: w.walletField, type: w.walletType },
testCreateInvoice: (data) => w.testCreateInvoice(data, { me, models })
}, { settings, data, priorityOnly }, { me, models })
wallet: {
field:
w.walletField,
type: w.walletType
},
testCreateInvoice: w.testCreateInvoice ? (data) => w.testCreateInvoice(data, { me, models }) : null
}, {
settings,
data,
priorityOnly,
canSend,
canReceive
}, { me, models })
}
}
console.groupEnd()
@ -158,14 +176,20 @@ const resolvers = {
})
return wallet
},
wallets: async (parent, args, { me, models }) => {
wallets: async (parent, { includeReceivers = true, includeSenders = true, onlyEnabled = false }, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
}
return await models.wallet.findMany({
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 (
{ wallet, testCreateInvoice }, { settings, data, priorityOnly }, { me, models }) {
if (!me) {
throw new GqlAuthenticationError()
}
{ wallet, testCreateInvoice },
{ settings, data, priorityOnly, canSend, canReceive },
{ me, models }
) {
if (!me) throw new GqlAuthenticationError()
assertApiKeyNotPermitted({ me })
if (testCreateInvoice && !priorityOnly) {
if (testCreateInvoice && !priorityOnly && canReceive) {
try {
await testCreateInvoice(data)
} catch (err) {
@ -655,70 +680,103 @@ async function upsertWallet (
priority
} = settings
const txs = [
models.user.update({
where: { id: me.id },
data: {
autoWithdrawMaxFeePercent,
autoWithdrawThreshold,
autoWithdrawMaxFeeTotal
}
})
]
return await models.$transaction(async (tx) => {
if (canReceive) {
tx.user.update({
where: { id: me.id },
data: {
autoWithdrawMaxFeePercent,
autoWithdrawThreshold
}
})
}
if (id) {
txs.push(
models.wallet.update({
let updatedWallet
if (id) {
const existingWalletTypeRecord = canReceive
? await tx[wallet.field].findUnique({
where: { walletId: Number(id) }
})
: undefined
updatedWallet = tx.wallet.update({
where: { id: Number(id), userId: me.id },
data: {
enabled,
priority,
[wallet.field]: {
update: {
where: { walletId: Number(id) },
data: walletData
}
}
canSend,
canReceive,
// if send-only config or priority only, don't update the wallet type record
...(canReceive && !priorityOnly
? {
[wallet.field]: existingWalletTypeRecord
? { update: walletData }
: { create: walletData }
}
: {})
},
include: {
...(canReceive && !priorityOnly ? { [wallet.field]: true } : {})
}
})
)
} else {
txs.push(
models.wallet.create({
} else {
updatedWallet = tx.wallet.create({
data: {
enabled,
priority,
canSend,
canReceive,
userId: me.id,
type: wallet.type,
[wallet.field]: {
create: walletData
}
// if send-only config or priority only, don't update the wallet type record
...(canReceive && !priorityOnly
? {
[wallet.field]: {
create: walletData
}
}
: {})
}
})
)
}
}
txs.push(
models.walletLog.createMany({
data: {
const logs = []
if (canReceive) {
logs.push({
userId: me.id,
wallet: wallet.type,
level: 'SUCCESS',
level: enabled ? 'SUCCESS' : 'INFO',
message: id ? 'receive details updated' : 'wallet attached for receives'
}
}),
models.walletLog.create({
data: {
})
logs.push({
userId: me.id,
wallet: wallet.type,
level: enabled ? 'SUCCESS' : 'INFO',
message: enabled ? 'receives enabled' : 'receives disabled'
}
})
)
})
}
await models.$transaction(txs)
return true
if (canSend) {
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 }) {

View File

@ -18,6 +18,7 @@ import admin from './admin'
import blockHeight from './blockHeight'
import chainFee from './chainFee'
import paidAction from './paidAction'
import vault from './vault'
const common = gql`
type Query {
@ -38,4 +39,4 @@ const common = gql`
`
export default [common, user, item, itemForward, message, wallet, lnurl, notifications, invite,
sub, upload, growth, rewards, referrals, price, admin, blockHeight, chainFee, paidAction]
sub, upload, growth, rewards, referrals, price, admin, blockHeight, chainFee, paidAction, vault]

View File

@ -46,7 +46,7 @@ export default gql`
disableFreebies: Boolean
}
type User {
type User implements VaultOwner {
id: ID!
createdAt: Date!
name: String
@ -182,7 +182,11 @@ export default gql`
withdrawMaxFeeDefault: Int!
autoWithdrawThreshold: Int
autoWithdrawMaxFeePercent: Float
<<<<<<< HEAD
autoWithdrawMaxFeeTotal: Int
=======
vaultKeyHash: String
>>>>>>> 002b1d19 (user vault and server side client wallets)
}
type UserOptional {

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

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

View File

@ -1,8 +1,7 @@
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 { isServerField } from 'wallets'
function injectTypeDefs (typeDefs) {
const injected = [rawTypeDefs(), mutationTypeDefs()]
@ -14,12 +13,13 @@ function mutationTypeDefs () {
const typeDefs = walletDefs.map((w) => {
let args = 'id: ID, '
args += w.fields
const serverFields = w.fields
.filter(isServerField)
.map(fieldToGqlArg).join(', ')
args += ', settings: AutowithdrawSettings!, priorityOnly: Boolean'
.map(fieldToGqlArgOptional)
if (serverFields.length > 0) args += serverFields.join(', ') + ','
args += 'settings: AutowithdrawSettings!, priorityOnly: Boolean, canSend: Boolean!, canReceive: Boolean!'
const resolverName = generateResolverName(w.walletField)
const typeDef = `${resolverName}(${args}): Boolean`
const typeDef = `${resolverName}(${args}): Wallet`
console.log(typeDef)
return typeDef
})
@ -33,11 +33,15 @@ function rawTypeDefs () {
console.group('injected GraphQL type defs:')
const typeDefs = walletDefs.map((w) => {
const args = w.fields
let args = w.fields
.filter(isServerField)
.map(fieldToGqlArg)
.map(s => ' ' + s)
.join('\n')
if (!args) {
// add a placeholder arg so the type is not empty
args = ' _empty: Boolean'
}
const typeDefName = generateTypeDefName(w.walletType)
const typeDef = `type ${typeDefName} {\n${args}\n}`
console.log(typeDef)
@ -63,7 +67,7 @@ const typeDefs = `
numBolt11s: Int!
connectAddress: String!
walletHistory(cursor: String, inc: String): History
wallets: [Wallet!]!
wallets(includeReceivers: Boolean, includeSenders: Boolean, onlyEnabled: Boolean): [Wallet!]!
wallet(id: ID!): Wallet
walletByType(type: String!): Wallet
walletLogs(type: String, from: String, to: String, cursor: String): WalletLog!
@ -79,13 +83,15 @@ const typeDefs = `
deleteWalletLogs(wallet: String): Boolean
}
type Wallet {
type Wallet implements VaultOwner {
id: ID!
createdAt: Date!
type: String!
enabled: Boolean!
priority: Int!
wallet: WalletDetails!
canReceive: Boolean!
canSend: Boolean!
}
input AutowithdrawSettings {

View File

@ -4,6 +4,6 @@ import Button from 'react-bootstrap/Button'
export default function CancelButton ({ onClick }) {
const router = useRouter()
return (
<Button className='me-4 text-muted nav-link fw-bold' variant='link' onClick={onClick || (() => router.back())}>cancel</Button>
<Button className='me-2 text-muted nav-link fw-bold' variant='link' onClick={onClick || (() => router.back())}>cancel</Button>
)
}

264
components/device-sync.js Normal file
View File

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

View File

@ -33,6 +33,12 @@ import EyeClose from '@/svgs/eye-close-line.svg'
import Info from './info'
import { useMe } from './me'
import classNames from 'classnames'
import Clipboard from '@/svgs/clipboard-line.svg'
import QrIcon from '@/svgs/qr-code-line.svg'
import QrScanIcon from '@/svgs/qr-scan-line.svg'
import { useShowModal } from './modal'
import QRCode from 'qrcode.react'
import { QrScanner } from '@yudiel/react-qr-scanner'
export class SessionRequiredError extends Error {
constructor () {
@ -69,31 +75,41 @@ export function SubmitButton ({
)
}
export function CopyInput (props) {
function CopyButton ({ value, icon, ...props }) {
const toaster = useToast()
const [copied, setCopied] = useState(false)
const handleClick = async () => {
const handleClick = useCallback(async () => {
try {
await copy(props.placeholder)
await copy(value)
toaster.success('copied')
setCopied(true)
setTimeout(() => setCopied(false), 1500)
} catch (err) {
toaster.danger('failed to copy')
}
}, [toaster, value])
if (icon) {
return (
<InputGroup.Text style={{ cursor: 'pointer' }} onClick={handleClick}>
<Clipboard height={20} width={20} />
</InputGroup.Text>
)
}
return (
<Button className={styles.appendButton} {...props} onClick={handleClick}>
{copied ? <Thumb width={18} height={18} /> : 'copy'}
</Button>
)
}
export function CopyInput (props) {
return (
<Input
onClick={handleClick}
append={
<Button
className={styles.appendButton}
size={props.size}
onClick={handleClick}
>{copied ? <Thumb width={18} height={18} /> : 'copy'}
</Button>
<CopyButton value={props.placeholder} size={props.size} />
}
{...props}
/>
@ -711,10 +727,11 @@ export function InputUserSuggest ({
)
}
export function Input ({ label, groupClassName, ...props }) {
export function Input ({ label, groupClassName, under, ...props }) {
return (
<FormGroup label={label} className={groupClassName}>
<InputInner {...props} />
{under}
</FormGroup>
)
}
@ -1070,24 +1087,121 @@ function PasswordHider ({ onClick, showPass }) {
>
{!showPass
? <Eye
fill='var(--bs-body-color)' height={20} width={20}
fill='var(--bs-body-color)' height={16} width={16}
/>
: <EyeClose
fill='var(--bs-body-color)' height={20} width={20}
fill='var(--bs-body-color)' height={16} width={16}
/>}
</InputGroup.Text>
)
}
export function PasswordInput ({ newPass, ...props }) {
function QrPassword ({ value }) {
const showModal = useShowModal()
const toaster = useToast()
const showQr = useCallback(() => {
showModal(close => (
<div 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 [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 (
<ClientInput
{...props}
className={styles.passwordInput}
type={showPass ? 'text' : 'password'}
autoComplete={newPass ? 'new-password' : 'current-password'}
append={<PasswordHider showPass={showPass} onClick={() => setShowPass(!showPass)} />}
readOnly={readOnly}
append={props.as === 'textarea' ? undefined : Append}
value={maskedValue}
under={props.as === 'textarea'
? (
<div className='mt-2 d-flex justify-content-end' style={{ gap: '8px' }}>
{Append}
</div>)
: undefined}
/>
)
}

View File

@ -2,6 +2,10 @@
border-top-left-radius: 0;
}
textarea.passwordInput {
resize: none;
}
.markdownInput textarea {
margin-top: -1px;
font-size: 94%;
@ -69,4 +73,16 @@
0% {
opacity: 42%;
}
}
}
div.qr {
display: grid;
}
div.qr>svg {
justify-self: center;
width: 100%;
height: auto;
padding: 1rem;
background-color: white;
}

View File

@ -45,13 +45,20 @@ export default function useModal () {
}, [getCurrentContent, forceUpdate])
// this is called on every navigation due to below useEffect
const onClose = useCallback(() => {
const onClose = useCallback((options) => {
if (options?.back) {
for (let i = 0; i < options.back; i++) {
onBack()
}
return
}
while (modalStack.current.length) {
getCurrentContent()?.options?.onClose?.()
modalStack.current.pop()
}
forceUpdate()
}, [])
}, [onBack])
const router = useRouter()
useEffect(() => {
@ -90,7 +97,7 @@ export default function useModal () {
{overflow}
</ActionDropdown>
</div>}
{modalStack.current.length > 1 ? <div className='modal-btn modal-back' onClick={onBack}><BackArrow width={18} height={18} className='fill-white' /></div> : null}
{modalStack.current.length > 1 ? <div className='modal-btn modal-back' onClick={onBack}><BackArrow width={18} height={18} /></div> : null}
<div className={'modal-btn modal-close ' + className} onClick={onClose}>X</div>
</div>
<Modal.Body className={className}>

View File

@ -25,6 +25,7 @@ import { useHasNewNotes } from '../use-has-new-notes'
import { useWallets } from 'wallets'
import SwitchAccountList, { useAccounts } from '@/components/account'
import { useShowModal } from '@/components/modal'
import { unsetLocalKey as resetVaultKey } from '@/components/use-vault'
export function Brand ({ className }) {
return (
@ -265,6 +266,7 @@ function LogoutObstacle ({ onClose }) {
const { registration: swRegistration, togglePushSubscription } = useServiceWorker()
const wallets = useWallets()
const { multiAuthSignout } = useAccounts()
const { me } = useMe()
return (
<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 resetVaultKey(me?.id)
await signOut({ callbackUrl: '/' })
}}

View File

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

426
components/use-vault.js Normal file
View File

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

View File

@ -145,7 +145,7 @@ export function useWalletLogger (wallet, setLogs) {
const log = useCallback(level => message => {
if (!wallet) {
console.error('cannot log: no wallet set')
// console.error('cannot log: no wallet set')
return
}

View File

@ -3,12 +3,68 @@ import { COMMENTS, COMMENTS_ITEM_EXT_FIELDS } from './comments'
import { ITEM_FIELDS, ITEM_FULL_FIELDS } from './items'
import { SUB_FULL_FIELDS } from './subs'
export const STREAK_FIELDS = gql`
fragment StreakFields on User {
optional {
streak
gunStreak
horseStreak
export const ME = gql`
{
me {
id
name
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)
}
`

70
fragments/vault.js Normal file
View File

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

View File

@ -188,7 +188,19 @@ export const WALLET_BY_TYPE = gql`
export const WALLETS = gql`
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
priority
type

View File

@ -3,6 +3,7 @@ import { GraphQLError } from 'graphql'
export const E_FORBIDDEN = 'E_FORBIDDEN'
export const E_UNAUTHENTICATED = 'E_UNAUTHENTICATED'
export const E_BAD_INPUT = 'E_BAD_INPUT'
export const E_VAULT_KEY_EXISTS = 'E_VAULT_KEY_EXISTS'
export class GqlAuthorizationError extends GraphQLError {
constructor (message) {
@ -17,7 +18,7 @@ export class GqlAuthenticationError extends GraphQLError {
}
export class GqlInputError extends GraphQLError {
constructor (message) {
super(message, { extensions: { code: E_BAD_INPUT } })
constructor (message, code) {
super(message, { extensions: { code: code || E_BAD_INPUT } })
}
}

20
lib/hex.js Normal file
View File

@ -0,0 +1,20 @@
/**
* Convert a buffer to a hex string
* @param {*} buffer - the buffer to convert
* @returns {string} - the hex string
*/
export function toHex (buffer) {
const byteArray = new Uint8Array(buffer)
const hexString = Array.from(byteArray, byte => byte.toString(16).padStart(2, '0')).join('')
return hexString
}
/**
* Convert a hex string to a buffer
* @param {string} hex - the hex string to convert
* @returns {ArrayBuffer} - the buffer
*/
export function fromHex (hex) {
const byteArray = new Uint8Array(hex.match(/.{1,2}/g).map(byte => parseInt(byte, 16)))
return byteArray.buffer
}

54
lib/task-queue.js Normal file
View File

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

View File

@ -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 deviceSyncSchema = object().shape({
passphrase: string().required('required')
.test(async (value, context) => {
const words = value ? value.trim().split(/[\s]+/) : []
for (const w of words) {
try {
await string().oneOf(bip39Words).validate(w)
} catch {
return context.createError({ message: `'${w}' is not a valid pairing phrase word` })
}
}
if (words.length < 12) {
return context.createError({ message: 'needs at least 12 words' })
}
return true
})
})

View File

@ -6,6 +6,11 @@ export function fieldToGqlArg (field) {
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}`
@ -15,3 +20,43 @@ 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
}

View File

@ -20,7 +20,7 @@ import { LoggerProvider } from '@/components/logger'
import { ChainFeeProvider } from '@/components/chain-fee.js'
import dynamic from 'next/dynamic'
import { HasNewNotesProvider } from '@/components/use-has-new-notes'
import WebLnProvider from '@/wallets/webln'
import { WebLnProvider } from '@/wallets/webln/client'
import { AccountProvider } from '@/components/account'
const PWAPrompt = dynamic(() => import('react-ios-pwa-prompt'), { ssr: false })

View File

@ -31,6 +31,7 @@ import { OverlayTrigger, Tooltip } from 'react-bootstrap'
import { useField } from 'formik'
import styles from './settings.module.css'
import { AuthBanner } from '@/components/banners'
import DeviceSync from '@/components/device-sync'
export const getServerSideProps = getGetServerSideProps({ query: SETTINGS, authRequired: true })
@ -606,6 +607,7 @@ export default function Settings ({ ssrData }) {
<div className='form-label'>saturday newsletter</div>
<Button href='https://mail.stacker.news/subscription/form' target='_blank'>(re)subscribe</Button>
{settings?.authMethods && <AuthMethods methods={settings.authMethods} apiKeyEnabled={settings.apiKeyEnabled} />}
<DeviceSync />
</div>
</div>
</Layout>

View File

@ -22,7 +22,7 @@ export default function WalletSettings () {
const { wallet: name } = router.query
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
// even though we use wallet.config as the initial value
// 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.
return {
...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
const validateProps = typeof wallet.fieldValidation === 'function'
? { validate: wallet.fieldValidation }
: { schema: wallet.fieldValidation }
const validateProps = typeof wallet?.fieldValidation === 'function'
? { validate: wallet?.fieldValidation }
: { schema: wallet?.fieldValidation }
return (
<CenterLayout>
<h2 className='pb-2'>{wallet.card.title}</h2>
<h6 className='text-muted text-center pb-3'><Text>{wallet.card.subtitle}</Text></h6>
{wallet.canSend && wallet.hasConfig > 0 && <WalletSecurityBanner />}
<h2 className='pb-2'>{wallet?.card?.title}</h2>
<h6 className='text-muted text-center pb-3'><Text>{wallet?.card?.subtitle}</Text></h6>
{wallet?.canSend && wallet?.hasConfig > 0 && <WalletSecurityBanner />}
<Form
initial={initial}
enableReinitialize
{...validateProps}
onSubmit={async ({ amount, ...values }) => {
try {
const newConfig = !wallet.isConfigured
const newConfig = !wallet?.isConfigured
// enable wallet if wallet was just configured
if (newConfig) {
@ -67,13 +67,13 @@ export default function WalletSettings () {
}
}}
>
<WalletFields wallet={wallet} />
{wallet.walletType
{wallet && <WalletFields wallet={wallet} />}
{wallet?.walletType
? <AutowithdrawSettings wallet={wallet} />
: (
<CheckboxGroup name='enabled'>
<Checkbox
disabled={!wallet.isConfigured}
disabled={!wallet?.isConfigured}
label='enabled'
name='enabled'
groupClassName='mb-0'
@ -83,7 +83,7 @@ export default function WalletSettings () {
<WalletButtonBar
wallet={wallet} onDelete={async () => {
try {
await wallet.delete()
await wallet?.delete()
toaster.success('saved settings')
router.push('/settings/wallets')
} catch (err) {
@ -95,7 +95,7 @@ export default function WalletSettings () {
/>
</Form>
<div className='mt-3 w-100'>
<WalletLogs wallet={wallet} embedded />
{wallet && <WalletLogs wallet={wallet} embedded />}
</div>
</CenterLayout>
)

View File

@ -92,7 +92,12 @@ export default function Wallet ({ ssrData }) {
return (
<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={
!draggable
? ''

View File

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

View File

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

View File

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

View File

@ -137,6 +137,8 @@ model User {
ItemUserAgg ItemUserAgg[]
oneDayReferrals OneDayReferral[] @relation("OneDayReferral_referrer")
oneDayReferrees OneDayReferral[] @relation("OneDayReferral_referrees")
vaultKeyHash String @default("")
vaultEntries Vault[] @relation("VaultEntries")
@@index([photoId])
@@index([createdAt], map: "users.created_at_index")
@ -179,6 +181,9 @@ enum WalletType {
LNBITS
NWC
PHOENIXD
BLINK
LNC
WEBLN
}
model Wallet {
@ -190,6 +195,8 @@ model Wallet {
enabled Boolean @default(true)
priority Int @default(0)
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
// 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")
}
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

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

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M7 4V2H17V4H20.0066C20.5552 4 21 4.44495 21 4.9934V21.0066C21 21.5552 20.5551 22 20.0066 22H3.9934C3.44476 22 3 21.5551 3 21.0066V4.9934C3 4.44476 3.44495 4 3.9934 4H7ZM7 6H5V20H19V6H17V8H7V6ZM9 4V6H15V4H9Z"></path></svg>

After

Width:  |  Height:  |  Size: 310 B

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

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M16 17V16H13V13H16V15H18V17H17V19H15V21H13V18H15V17H16ZM21 21H17V19H19V17H21V21ZM3 3H11V11H3V3ZM5 5V9H9V5H5ZM13 3H21V11H13V3ZM15 5V9H19V5H15ZM3 13H11V21H3V13ZM5 15V19H9V15H5ZM18 13H21V15H18V13ZM6 6H8V8H6V6ZM6 16H8V18H6V16ZM16 6H18V8H16V6Z"></path></svg>

After

Width:  |  Height:  |  Size: 342 B

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

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M21 16V21H3V16H5V19H19V16H21ZM3 11H21V13H3V11ZM21 8H19V5H5V8H3V3H21V8Z"></path></svg>

After

Width:  |  Height:  |  Size: 174 B

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

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M5.46257 4.43262C7.21556 2.91688 9.5007 2 12 2C17.5228 2 22 6.47715 22 12C22 14.1361 21.3302 16.1158 20.1892 17.7406L17 12H20C20 7.58172 16.4183 4 12 4C9.84982 4 7.89777 4.84827 6.46023 6.22842L5.46257 4.43262ZM18.5374 19.5674C16.7844 21.0831 14.4993 22 12 22C6.47715 22 2 17.5228 2 12C2 9.86386 2.66979 7.88416 3.8108 6.25944L7 12H4C4 16.4183 7.58172 20 12 20C14.1502 20 16.1022 19.1517 17.5398 17.7716L18.5374 19.5674Z"></path></svg>

After

Width:  |  Height:  |  Size: 524 B

View File

@ -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.
- `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[]`
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).

View File

@ -4,6 +4,10 @@ export const galoyBlinkUrl = 'https://api.blink.sv/graphql'
export const galoyBlinkDashboardUrl = 'https://dashboard.blink.sv/'
export const name = 'blink'
export const walletType = 'BLINK'
export const walletField = 'walletBlink'
export const fieldValidation = blinkSchema
export const clientOnly = true
export const fields = [
{
@ -30,5 +34,3 @@ export const card = {
subtitle: 'use [Blink](https://blink.sv/) for payments',
badges: ['send only']
}
export const fieldValidation = blinkSchema

View File

@ -1,6 +1,9 @@
import { CLNAutowithdrawSchema } from '@/lib/validate'
export const name = 'cln'
export const walletType = 'CLN'
export const walletField = 'walletCLN'
export const fieldValidation = CLNAutowithdrawSchema
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)',
badges: ['receive only']
}
export const fieldValidation = CLNAutowithdrawSchema
export const walletType = 'CLN'
export const walletField = 'walletCLN'

View File

@ -1,17 +1,16 @@
import { useCallback } from 'react'
import { useCallback, useState, useEffect, useRef, useMemo } from 'react'
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 { SSR } from '@/lib/constants'
import { bolt11Tags } from '@/lib/bolt11'
import walletDefs from 'wallets/client'
import { gql, useApolloClient, useMutation, useQuery } from '@apollo/client'
import { REMOVE_WALLET, WALLET_BY_TYPE } from '@/fragments/wallet'
import { REMOVE_WALLET, WALLET_BY_TYPE, BEST_SEND_WALLETS } from '@/fragments/wallet'
import { autowithdrawInitial } from '@/components/autowithdraw-shared'
import { useShowModal } from '@/components/modal'
import { useToast } from '../components/toast'
import { generateResolverName } from '@/lib/wallet'
import { generateResolverName, isConfigured, isClientField, isServerField } from '@/lib/wallet'
import { walletValidate } from '@/lib/validate'
export const Status = {
@ -27,100 +26,125 @@ export function useWallet (name) {
const toaster = useToast()
const [disableFreebies] = useMutation(gql`mutation { disableFreebies }`)
const wallet = name ? getWalletByName(name) : getEnabledWallet(me)
const { logger, deleteLogs } = useWalletLogger(wallet)
const { data: bestSendWalletList } = useQuery(BEST_SEND_WALLETS)
const [config, saveConfig, clearConfig] = useConfig(wallet)
const hasConfig = wallet?.fields.length > 0
const _isConfigured = isConfigured({ ...wallet, config })
if (!name) {
// find best wallet in list
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(() => {
enableWallet(name, me)
logger.ok('payments enabled')
disableFreebies().catch(console.error)
}, [name, me, logger])
const walletDef = getWalletByName(name)
const disablePayments = useCallback(() => {
disableWallet(name, me)
logger.info('payments disabled')
}, [name, me, logger])
const { logger, deleteLogs } = useWalletLogger(walletDef)
const [config, saveConfig, clearConfig] = useConfig(walletDef)
const status = config?.enabled ? 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])
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 hash = bolt11Tags(bolt11).payment_hash
logger.info('sending payment:', `payment_hash=${hash}`)
try {
const preimage = await wallet.sendPayment(bolt11, config, { me, logger, status, showModal })
const preimage = await walletDef.sendPayment(bolt11, config, { me, logger, status, showModal })
logger.ok('payment successful:', `payment_hash=${hash}`, `preimage=${preimage}`)
} catch (err) {
const message = err.message || err.toString?.()
logger.error('payment failed:', `payment_hash=${hash}`, message)
throw err
}
}, [me, wallet, config, logger, status])
}, [me, walletDef, config, status])
const setPriority = useCallback(async (priority) => {
if (_isConfigured && priority !== config.priority) {
if (_isConfigured() && priority !== config.priority) {
try {
await saveConfig({ ...config, priority }, { logger, priorityOnly: true })
await saveConfig({ ...config, priority }, { logger, skipTests: true })
} 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) => {
await saveConfig(newConfig, { logger })
}, [saveConfig, me, logger])
}, [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, logger, disablePayments])
}, [clearConfig])
const deleteLogs_ = useCallback(async (options) => {
// first argument is to override the wallet
return await deleteLogs(options)
}, [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
// `useWallet` hook has access to all others via the reference to it.
// Essentially, you can now use functions like `enablePayments` _inside_ of functions that are
// called by `useWallet` even before enablePayments is defined and not only in functions
// that use the return value of `useWallet`.
wallet.isConfigured = _isConfigured
wallet.enablePayments = enablePayments
wallet.disablePayments = disablePayments
wallet.canSend = !!wallet.sendPayment
wallet.canReceive = !!wallet.createInvoice
wallet.config = config
wallet.save = save
wallet.delete = delete_
wallet.deleteLogs = deleteLogs_
wallet.setPriority = setPriority
wallet.hasConfig = hasConfig
wallet.status = status
wallet.enabled = enabled
wallet.priority = priority
wallet.logger = logger
useEffect(() => {
if (wallet.enabled && wallet.canSend) {
disableFreebies().catch(console.error)
logger.ok('payments enabled')
}
}, [wallet])
// can't assign sendPayment to wallet object because it already exists
// as an imported function and thus can't be overwritten
return { ...wallet, sendPayment }
return wallet
}
function extractConfig (fields, config, client) {
function extractConfig (fields, config, client, includeMeta = true) {
return Object.entries(config).reduce((acc, [key, value]) => {
const field = fields.find(({ name }) => name === key)
@ -129,7 +153,7 @@ function extractConfig (fields, config, client) {
if (client && key === 'id') return acc
// field might not exist because config.enabled doesn't map to a wallet field
if (!field || (client ? isClientField(field) : isServerField(field))) {
if ((!field && includeMeta) || (field && (client ? isClientField(field) : isServerField(field)))) {
return {
...acc,
[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) {
return extractConfig(fields, config, true)
return extractConfig(fields, config, true, false)
}
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 toaster = useToast()
const autowithdrawSettings = autowithdrawInitial({ me })
const clientVault = useRef(null)
const storageKey = getStorageKey(wallet?.name, me)
const [clientConfig, setClientConfig, clearClientConfig] = useClientConfig(storageKey, {})
const [config, innerSetConfig] = useState({})
const [currentWallet, innerSetCurrentWallet] = useState(null)
const [serverConfig, setServerConfig, clearServerConfig] = useServerConfig(wallet)
const canSend = !!walletDef?.sendPayment
const canReceive = !walletDef?.clientOnly
const hasClientConfig = !!wallet?.sendPayment
const hasServerConfig = !!wallet?.walletType
const refreshConfig = useCallback(async () => {
if (walletDef) {
let newConfig = {}
newConfig = {
...autowithdrawSettings
}
let config = {}
if (hasClientConfig) config = clientConfig
if (hasServerConfig) {
const { enabled, priority } = config || {}
config = {
...config,
...serverConfig
// fetch server config
const serverConfig = await client.query({
query: WALLET_BY_TYPE,
variables: { type: walletDef.walletType },
fetchPolicy: 'no-cache'
})
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
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
}
}, [walletDef, me])
const saveConfig = useCallback(async (newConfig, { logger, priorityOnly }) => {
// NOTE:
// verifying the client/server configuration before saving it
// prevents unsetting just one configuration if both are set.
// This means there is no way of unsetting just one configuration
// since 'detach' detaches both.
// Not optimal UX but the trade-off is saving invalid configurations
// and maybe it's not that big of an issue.
if (hasClientConfig) {
let newClientConfig = extractClientConfig(wallet.fields, newConfig)
useEffect(() => {
refreshConfig()
}, [walletDef, me])
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 {
const transformedConfig = await walletValidate(wallet, newClientConfig)
const transformedConfig = await walletValidate(walletDef, newClientConfig)
if (transformedConfig) {
newClientConfig = Object.assign(newClientConfig, transformedConfig)
}
// these are stored on the server
delete newClientConfig.autoWithdrawMaxFeePercent
delete newClientConfig.autoWithdrawThreshold
delete newClientConfig.autoWithdrawMaxFeeTotal
} catch {
valid = false
} catch (e) {
newClientConfig = {}
}
if (valid) {
if (priorityOnly) {
setClientConfig(newClientConfig)
} else {
try {
// XXX: testSendPayment can return a new config (e.g. lnc)
const newerConfig = await wallet.testSendPayment?.(newConfig, { me, logger })
if (newerConfig) {
newClientConfig = Object.assign(newClientConfig, newerConfig)
}
} catch (err) {
logger.error(err.message)
throw err
}
setClientConfig(newClientConfig)
logger.ok(wallet.isConfigured ? 'payment details updated' : 'wallet attached for payments')
if (newConfig.enabled) wallet.enablePayments()
else wallet.disablePayments()
}
}
}
if (hasServerConfig) {
let newServerConfig = extractServerConfig(wallet.fields, newConfig)
let valid = true
let newServerConfig = extractServerConfig(walletDef.fields, newConfig)
try {
const transformedConfig = await walletValidate(wallet, newServerConfig)
const transformedConfig = await walletValidate(walletDef, newServerConfig)
if (transformedConfig) {
newServerConfig = Object.assign(newServerConfig, transformedConfig)
}
} catch {
valid = false
} catch (e) {
newServerConfig = {}
}
if (valid) await setServerConfig(newServerConfig, { priorityOnly })
}
}, [hasClientConfig, hasServerConfig, setClientConfig, setServerConfig, wallet])
// 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 clearConfig = useCallback(async ({ logger, clientOnly }) => {
if (hasClientConfig) {
clearClientConfig()
wallet.disablePayments()
logger.ok('wallet detached for payments')
}
if (hasServerConfig && !clientOnly) await clearServerConfig()
}, [hasClientConfig, hasServerConfig, clearClientConfig, clearServerConfig, wallet])
// console.log('New client config', newClientConfig)
// console.log('New server config', newServerConfig)
// console.log('Sender', isReadyToSend, 'Receiver', isReadyToReceive, 'enabled', enabled)
return [config, saveConfig, clearConfig]
}
function isConfigured ({ fields, config }) {
if (!config || !fields) return false
// a wallet is configured if all of its required fields are set
let val = fields.every(f => {
return f.optional ? true : !!config?.[f.name]
})
// however, a wallet is not configured if all fields are optional and none are set
// since that usually means that one of them is required
if (val && fields.length > 0) {
val = !(fields.every(f => f.optional) && fields.every(f => !config?.[f.name]))
}
return val
}
function useServerConfig (wallet) {
const client = useApolloClient()
const { me } = useMe()
const { data, refetch: refetchConfig } = useQuery(WALLET_BY_TYPE, { variables: { type: wallet?.walletType }, skip: !wallet?.walletType })
const walletId = data?.walletByType?.id
const serverConfig = {
id: walletId,
priority: data?.walletByType?.priority,
enabled: data?.walletByType?.enabled,
...data?.walletByType?.wallet
}
delete serverConfig.__typename
const autowithdrawSettings = autowithdrawInitial({ me })
const config = { ...serverConfig, ...autowithdrawSettings }
const saveConfig = useCallback(async ({
autoWithdrawThreshold,
autoWithdrawMaxFeePercent,
autoWithdrawMaxFeeTotal,
priority,
enabled,
...config
}, { priorityOnly }) => {
try {
const mutation = generateMutation(wallet)
return await client.mutate({
mutation,
variables: {
...config,
id: walletId,
settings: {
autoWithdrawThreshold: Number(autoWithdrawThreshold),
autoWithdrawMaxFeePercent: Number(autoWithdrawMaxFeePercent),
autoWithdrawMaxFeeTotal: Number(autoWithdrawMaxFeeTotal),
priority,
enabled
},
priorityOnly
// client test
if (!skipTests && isReadyToSend) {
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)
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 {
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
if (!walletId) return
if (!currentWallet?.id) return
try {
await client.mutate({
mutation: REMOVE_WALLET,
variables: { id: walletId }
})
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'] })
refetchConfig()
await refreshConfig()
}
}, [client, walletId])
}, [config, currentWallet])
return [config, saveConfig, clearConfig]
}
@ -350,22 +386,30 @@ function generateMutation (wallet) {
headerArgs += wallet.fields
.filter(isServerField)
.map(f => {
let arg = `$${f.name}: String`
if (!f.optional) {
arg += '!'
}
const arg = `$${f.name}: String`
// required fields are checked server-side
// if (!f.optional) {
// arg += '!'
// }
return arg
}).join(', ')
headerArgs += ', $settings: AutowithdrawSettings!, $priorityOnly: Boolean'
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'
inputArgs += ', settings: $settings, priorityOnly: $priorityOnly, canSend: $canSend, canReceive: $canReceive,'
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)
}
export function getEnabledWallet (me) {
return walletDefs
.filter(def => !!def.sendPayment)
.map(def => {
// populate definition with properties from useWallet that are required for sorting
const key = getStorageKey(def.name, me)
const config = SSR ? null : JSON.parse(window?.localStorage.getItem(key))
const priority = config?.priority
return { ...def, config, priority }
})
.filter(({ config }) => config?.enabled)
.sort(walletPrioritySort)[0]
}
export function walletPrioritySort (w1, w2) {
const delta = w1.priority - w2.priority
// delta is NaN if either priority is undefined
@ -416,37 +446,16 @@ export function useWallets () {
const resetClient = useCallback(async (wallet) => {
for (const w of wallets) {
if (w.canSend) {
await w.delete({ clientOnly: true })
await w.delete({ clientOnly: true, onlyFromLocalStorage: true })
}
await w.deleteLogs({ clientOnly: true })
}
}, [wallets])
}, wallets)
return { wallets, resetClient }
}
function getStorageKey (name, me) {
let storageKey = `wallet:${name}`
// WebLN has no credentials we need to scope to users
// so we can use the same storage key for all users
if (me && name !== 'webln') {
storageKey = `${storageKey}:${me.id}`
}
return storageKey
}
function enableWallet (name, me) {
const key = getStorageKey(name, me)
const config = JSON.parse(window.localStorage.getItem(key)) || {}
config.enabled = true
window.localStorage.setItem(key, JSON.stringify(config))
}
function disableWallet (name, me) {
const key = getStorageKey(name, me)
const config = JSON.parse(window.localStorage.getItem(key)) || {}
config.enabled = false
window.localStorage.setItem(key, JSON.stringify(config))
const [walletsReady, setWalletsReady] = useState([])
useEffect(() => {
setWalletsReady(wallets.filter(w => w))
}, wallets)
return { wallets: walletsReady, resetClient }
}

View File

@ -2,6 +2,9 @@ import { lnAddrAutowithdrawSchema } from '@/lib/validate'
export const name = 'lightning-address'
export const shortName = 'lnAddr'
export const walletType = 'LIGHTNING_ADDRESS'
export const walletField = 'walletLightningAddress'
export const fieldValidation = lnAddrAutowithdrawSchema
export const fields = [
{
@ -17,9 +20,3 @@ export const card = {
subtitle: 'autowithdraw to a lightning address',
badges: ['receive only']
}
export const fieldValidation = lnAddrAutowithdrawSchema
export const walletType = 'LIGHTNING_ADDRESS'
export const walletField = 'walletLightningAddress'

View File

@ -1,6 +1,9 @@
import { lnbitsSchema } from '@/lib/validate'
export const name = 'lnbits'
export const walletType = 'LNBITS'
export const walletField = 'walletLNbits'
export const fieldValidation = lnbitsSchema
export const fields = [
{
@ -31,9 +34,3 @@ export const card = {
subtitle: 'use [LNbits](https://lnbits.com/) for payments',
badges: ['send & receive']
}
export const fieldValidation = lnbitsSchema
export const walletType = 'LNBITS'
export const walletField = 'walletLNbits'

View File

@ -1,6 +1,10 @@
import { lncSchema } from '@/lib/validate'
export const name = 'lnc'
export const walletType = 'LNC'
export const walletField = 'walletLNC'
export const clientOnly = true
export const fieldValidation = lncSchema
export const fields = [
{
@ -35,5 +39,3 @@ export const card = {
subtitle: 'use Lightning Node Connect for LND payments',
badges: ['send only', 'budgetable']
}
export const fieldValidation = lncSchema

View File

@ -1,6 +1,9 @@
import { LNDAutowithdrawSchema } from '@/lib/validate'
export const name = 'lnd'
export const walletType = 'LND'
export const walletField = 'walletLND'
export const fieldValidation = LNDAutowithdrawSchema
export const fields = [
{
@ -39,9 +42,3 @@ export const card = {
subtitle: 'autowithdraw to your Lightning Labs node',
badges: ['receive only']
}
export const fieldValidation = LNDAutowithdrawSchema
export const walletType = 'LND'
export const walletField = 'walletLND'

View File

@ -4,6 +4,9 @@ import { nwcSchema } from '@/lib/validate'
import { finalizeEvent, nip04, verifyEvent } from 'nostr-tools'
export const name = 'nwc'
export const walletType = 'NWC'
export const walletField = 'walletNWC'
export const fieldValidation = nwcSchema
export const fields = [
{
@ -30,12 +33,6 @@ export const card = {
badges: ['send & receive', 'budgetable']
}
export const fieldValidation = nwcSchema
export const walletType = 'NWC'
export const walletField = 'walletNWC'
export async function nwcCall ({ nwcUrl, method, params }, { logger, timeout } = {}) {
const { relayUrl, walletPubkey, secret } = parseNwcUrl(nwcUrl)

View File

@ -1,6 +1,9 @@
import { phoenixdSchema } from '@/lib/validate'
export const name = 'phoenixd'
export const walletType = 'PHOENIXD'
export const walletField = 'walletPhoenixd'
export const fieldValidation = phoenixdSchema
// configure wallet fields
export const fields = [
@ -38,8 +41,3 @@ export const card = {
// phoenixd::TODO
// set validation schema
export const fieldValidation = phoenixdSchema
export const walletType = 'PHOENIXD'
export const walletField = 'walletPhoenixd'

View File

@ -1,23 +1,31 @@
// import server side wallets
import * as lnd from 'wallets/lnd/server'
import * as cln from 'wallets/cln/server'
import * as lnAddr from 'wallets/lightning-address/server'
import * as lnbits from 'wallets/lnbits/server'
import * as nwc from 'wallets/nwc/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 walletDefs from 'wallets/server'
import { parsePaymentRequest } from 'ln-service'
import { toPositiveNumber } from '@/lib/validate'
import { PAID_ACTION_TERMINAL_STATES } from '@/lib/constants'
import { withTimeout } from '@/lib/time'
export default [lnd, cln, lnAddr, lnbits, nwc, phoenixd]
export default [lnd, cln, lnAddr, lnbits, nwc, phoenixd, blink, lnc, webln]
const MAX_PENDING_INVOICES_PER_WALLET = 25
export async function createInvoice (userId, { msats, description, descriptionHash, expiry = 360 }, { models }) {
// get the wallets in order of priority
const wallets = await models.wallet.findMany({
where: { userId, enabled: true },
where: { userId, enabled: true, canReceive: true },
include: {
user: true
},

View File

@ -1,3 +1,4 @@
import { useEffect } from 'react'
export * from 'wallets/webln'
export const sendPayment = async (bolt11) => {
@ -19,3 +20,32 @@ export const sendPayment = async (bolt11) => {
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
}

View File

@ -1,12 +1,12 @@
import { useEffect } from 'react'
import { useWallet } from 'wallets'
export const name = 'webln'
export const walletType = 'WEBLN'
export const walletField = 'walletWebLN'
export const clientOnly = true
export const fields = []
export const fieldValidation = ({ enabled }) => {
if (typeof window.webln === 'undefined') {
if (typeof window?.webln === 'undefined') {
// don't prevent disabling WebLN if no WebLN provider found
if (enabled) {
return {
@ -22,27 +22,3 @@ export const card = {
subtitle: 'use a [WebLN provider](https://www.webln.guide/ressources/webln-providers) for payments',
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
}