diff --git a/.gitignore b/.gitignore index a01a1b4d..e7568828 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,7 @@ docker-compose.*.yml *.sql !/prisma/migrations/*/*.sql !/docker/db/seed.sql +!/docker/db/wallet-seed.sql # nostr wallet connect scripts/nwc-keys.json diff --git a/api/paidAction/index.js b/api/paidAction/index.js index cd627987..0153bf64 100644 --- a/api/paidAction/index.js +++ b/api/paidAction/index.js @@ -227,7 +227,7 @@ async function performP2PAction (actionType, args, incomingContext) { await assertBelowMaxPendingInvoices(incomingContext) const description = await paidActions[actionType].describe(args, incomingContext) - const { invoice, wrappedInvoice, wallet, maxFee } = await createWrappedInvoice(userId, { + const { invoice, wrappedInvoice, protocol, maxFee } = await createWrappedInvoice(userId, { msats: cost, feePercent: sybilFeePercent, description, @@ -239,7 +239,7 @@ async function performP2PAction (actionType, args, incomingContext) { invoiceArgs: { bolt11: invoice, wrappedBolt11: wrappedInvoice, - wallet, + protocol, maxFee } } @@ -269,7 +269,7 @@ async function performDirectAction (actionType, args, incomingContext) { const description = actionDescription ?? await paidActions[actionType].describe(args, incomingContext) - for await (const { invoice, logger, wallet } of createUserInvoice(userId, { + for await (const { invoice, logger, protocol } of createUserInvoice(userId, { msats: cost, description, expiry: INVOICE_EXPIRE_SECS @@ -293,7 +293,7 @@ async function performDirectAction (actionType, args, incomingContext) { bolt11: invoice, msats: cost, hash, - walletId: wallet.id, + protocolId: protocol.id, receiverId: userId } }), @@ -346,22 +346,26 @@ export async function retryPaidAction (actionType, args, incomingContext) { invoiceId: failedInvoice.id }, include: { - wallet: true + protocol: { + include: { + wallet: true + } + } } }) if (invoiceForward) { // this is a wrapped invoice, we need to retry it with receiver fallbacks try { - const { userId } = invoiceForward.wallet + const { userId } = invoiceForward.protocol.wallet // this will return an invoice from the first receiver wallet that didn't fail yet and throw if none is available - const { invoice: bolt11, wrappedInvoice: wrappedBolt11, wallet, maxFee } = await createWrappedInvoice(userId, { + const { invoice: bolt11, wrappedInvoice: wrappedBolt11, protocol, maxFee } = await createWrappedInvoice(userId, { msats: failedInvoice.msatsRequested, feePercent: await action.getSybilFeePercent?.(actionArgs, retryContext), description: await action.describe?.(actionArgs, retryContext), expiry: INVOICE_EXPIRE_SECS }, retryContext) - invoiceArgs = { bolt11, wrappedBolt11, wallet, maxFee } + invoiceArgs = { bolt11, wrappedBolt11, protocol, maxFee } } catch (err) { console.log('failed to retry wrapped invoice, falling back to SN:', err) } @@ -429,7 +433,7 @@ async function createSNInvoice (actionType, args, context) { async function createDbInvoice (actionType, args, context) { const { me, models, tx, cost, optimistic, actionId, invoiceArgs, paymentAttempt, predecessorId } = context - const { bolt11, wrappedBolt11, preimage, wallet, maxFee } = invoiceArgs + const { bolt11, wrappedBolt11, preimage, protocol, maxFee } = invoiceArgs const db = tx ?? models @@ -468,9 +472,9 @@ async function createDbInvoice (actionType, args, context) { invoice: { create: invoiceData }, - wallet: { + protocol: { connect: { - id: wallet.id + id: protocol.id } } } diff --git a/api/paidAction/zap.js b/api/paidAction/zap.js index 66b4d6a0..5ebecba9 100644 --- a/api/paidAction/zap.js +++ b/api/paidAction/zap.js @@ -39,11 +39,11 @@ export async function getInvoiceablePeer ({ id, sats, hasSendWallet }, { models, return null } - const wallets = await getInvoiceableWallets(item.userId, { models }) + const protocols = await getInvoiceableWallets(item.userId, { models }) // request peer invoice if they have an attached wallet and have not forwarded the item // and the receiver doesn't want to receive credits - if (wallets.length > 0 && + if (protocols.length > 0 && item.itemForwards.length === 0 && sats >= item.user.receiveCreditsBelowSats) { return item.userId diff --git a/api/payingAction/index.js b/api/payingAction/index.js index 2ff7117a..cdc6db5c 100644 --- a/api/payingAction/index.js +++ b/api/payingAction/index.js @@ -6,9 +6,9 @@ import { parsePaymentRequest, payViaPaymentRequest } from 'ln-service' // paying actions are completely distinct from paid actions // and there's only one paying action: send // ... still we want the api to at least be similar -export default async function performPayingAction ({ bolt11, maxFee, walletId }, { me, models, lnd }) { +export default async function performPayingAction ({ bolt11, maxFee, protocolId }, { me, models, lnd }) { try { - console.group('performPayingAction', `${bolt11.slice(0, 10)}...`, maxFee, walletId) + console.group('performPayingAction', `${bolt11.slice(0, 10)}...`, maxFee, protocolId) if (!me) { throw new Error('You must be logged in to perform this action') @@ -34,8 +34,8 @@ export default async function performPayingAction ({ bolt11, maxFee, walletId }, msatsPaying: toPositiveBigInt(decoded.mtokens), msatsFeePaying: satsToMsats(maxFee), userId: me.id, - walletId, - autoWithdraw: !!walletId + protocolId, + autoWithdraw: !!protocolId } }) }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }) diff --git a/api/resolvers/index.js b/api/resolvers/index.js index eccfaf1d..65794f8e 100644 --- a/api/resolvers/index.js +++ b/api/resolvers/index.js @@ -1,7 +1,8 @@ import user from './user' import message from './message' import item from './item' -import wallet from './wallet' +import walletV1 from './wallet' +import walletV2 from '@/wallets/server/resolvers' import lnurl from './lnurl' import notifications from './notifications' import invite from './invite' @@ -19,7 +20,6 @@ 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', @@ -54,6 +54,6 @@ const limit = createIntScalar({ maximum: 1000 }) -export default [user, item, message, wallet, lnurl, notifications, invite, sub, +export default [user, item, message, walletV1, walletV2, lnurl, notifications, invite, sub, upload, search, growth, rewards, referrals, price, admin, blockHeight, chainFee, - { JSONObject }, { Date: date }, { Limit: limit }, paidAction, vault] + { JSONObject }, { Date: date }, { Limit: limit }, paidAction] diff --git a/api/resolvers/vault.js b/api/resolvers/vault.js deleted file mode 100644 index 1211adaf..00000000 --- a/api/resolvers/vault.js +++ /dev/null @@ -1,53 +0,0 @@ -import { E_VAULT_KEY_EXISTS, GqlAuthenticationError, GqlInputError } from '@/lib/error' - -export default { - Query: { - getVaultEntries: async (parent, args, { me, models }) => { - if (!me) throw new GqlAuthenticationError() - - return await models.vaultEntry.findMany({ where: { userId: me.id } }) - } - }, - Mutation: { - // atomic vault migration - updateVaultKey: async (parent, { entries, hash }, { me, models }) => { - if (!me) throw new GqlAuthenticationError() - if (!hash) throw new GqlInputError('hash required') - const txs = [] - - const { vaultKeyHash: oldKeyHash } = await models.user.findUnique({ where: { id: me.id } }) - if (oldKeyHash) { - if (oldKeyHash !== hash) { - throw new GqlInputError('vault key already set', E_VAULT_KEY_EXISTS) - } else { - return true - } - } else { - txs.push(models.user.update({ - where: { id: me.id }, - data: { vaultKeyHash: hash } - })) - } - - for (const entry of entries) { - txs.push(models.vaultEntry.update({ - where: { userId_key: { userId: me.id, key: entry.key } }, - data: { value: entry.value, iv: entry.iv } - })) - } - await models.$transaction(txs) - return true - }, - clearVault: async (parent, args, { me, models }) => { - if (!me) throw new GqlAuthenticationError() - const txs = [] - txs.push(models.user.update({ - where: { id: me.id }, - data: { vaultKeyHash: '' } - })) - txs.push(models.vaultEntry.deleteMany({ where: { userId: me.id } })) - await models.$transaction(txs) - return true - } - } -} diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index c97eac79..67d0a400 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -8,7 +8,6 @@ import { SELECT, itemQueryWithMeta } from './item' import { formatMsats, msatsToSats, msatsToSatsDecimal } from '@/lib/format' import { USER_ID, INVOICE_RETENTION_DAYS, - WALLET_CREATE_INVOICE_TIMEOUT_MS, WALLET_RETRY_AFTER_MS, WALLET_RETRY_BEFORE_MS, WALLET_MAX_RETRIES @@ -18,76 +17,12 @@ import assertGofacYourself from './ofac' import assertApiKeyNotPermitted from './apiKey' import { bolt11Tags } from '@/lib/bolt11' import { finalizeHodlInvoice } from '@/worker/wallet' -import walletDefs from '@/wallets/server' -import { generateResolverName, generateTypeDefName } from '@/wallets/graphql' import { lnAddrOptions } from '@/lib/lnurl' import { GqlAuthenticationError, GqlAuthorizationError, GqlInputError } from '@/lib/error' import { getNodeSockets } from '../lnd' -import validateWallet from '@/wallets/validate' -import { canReceive, getWalletByType } from '@/wallets/common' import performPaidAction from '../paidAction' import performPayingAction from '../payingAction' -import { timeoutSignal, withTimeout } from '@/lib/time' - -function injectResolvers (resolvers) { - console.group('injected GraphQL resolvers:') - for (const walletDef of walletDefs) { - const resolverName = generateResolverName(walletDef.walletField) - console.log(resolverName) - resolvers.Mutation[resolverName] = async (parent, { settings, validateLightning, vaultEntries, ...data }, { me, models }) => { - console.log('resolving', resolverName, { settings, validateLightning, vaultEntries, ...data }) - - let existingVaultEntries - if (typeof vaultEntries === 'undefined' && data.id) { - // this mutation was sent from an unsynced client - // to pass validation, we need to add the existing vault entries for validation - // in case the client is removing the receiving config - existingVaultEntries = await models.vaultEntry.findMany({ - where: { - walletId: Number(data.id) - } - }) - } - - const validData = await validateWallet(walletDef, - { ...data, ...settings, vaultEntries: vaultEntries ?? existingVaultEntries }, - { serverSide: true }) - if (validData) { - data && Object.keys(validData).filter(key => key in data).forEach(key => { data[key] = validData[key] }) - settings && Object.keys(validData).filter(key => key in settings).forEach(key => { settings[key] = validData[key] }) - } - - // wallet in shape of db row - const wallet = { - field: walletDef.walletField, - type: walletDef.walletType, - userId: me?.id - } - const logger = walletLogger({ wallet, models }) - - return await upsertWallet({ - wallet, - walletDef, - testCreateInvoice: - walletDef.testCreateInvoice && validateLightning && canReceive({ def: walletDef, config: data }) - ? (data) => withTimeout( - walletDef.testCreateInvoice(data, { - logger, - signal: timeoutSignal(WALLET_CREATE_INVOICE_TIMEOUT_MS) - }), - WALLET_CREATE_INVOICE_TIMEOUT_MS) - : null - }, { - settings, - data, - vaultEntries - }, { logger, me, models }) - } - } - console.groupEnd() - - return resolvers -} +import { logContextFromBolt11 } from '@/wallets/server' export async function getInvoice (parent, { id }, { me, models, lnd }) { const inv = await models.invoice.findUnique({ @@ -153,23 +88,6 @@ export function verifyHmac (hash, hmac) { const resolvers = { Query: { invoice: getInvoice, - wallets: async (parent, args, { me, models }) => { - if (!me) { - throw new GqlAuthenticationError() - } - - return await models.wallet.findMany({ - include: { - vaultEntries: true - }, - where: { - userId: me.id - }, - orderBy: { - priority: 'asc' - } - }) - }, withdrawl: getWithdrawl, direct: async (parent, { id }, { me, models }) => { if (!me) { @@ -375,67 +293,6 @@ const resolvers = { facts: history } }, - walletLogs: async (parent, { type, from, to, cursor }, { me, models }) => { - if (!me) { - throw new GqlAuthenticationError() - } - - // we cursoring with the wallet logs on the client - // if we have from, don't use cursor - // regardless, store the state of the cursor for the next call - - const decodedCursor = cursor ? decodeCursor(cursor) : { offset: 0, time: to ?? new Date() } - - let logs = [] - let nextCursor - if (from) { - logs = await models.walletLog.findMany({ - where: { - userId: me.id, - wallet: type ?? undefined, - createdAt: { - gt: from ? new Date(Number(from)) : undefined, - lte: to ? new Date(Number(to)) : undefined - } - }, - include: { - invoice: true, - withdrawal: true - }, - orderBy: [ - { createdAt: 'desc' }, - { id: 'desc' } - ] - }) - nextCursor = nextCursorEncoded(decodedCursor, logs.length) - } else { - logs = await models.walletLog.findMany({ - where: { - userId: me.id, - wallet: type ?? undefined, - createdAt: { - lte: decodedCursor.time - } - }, - include: { - invoice: true, - withdrawal: true - }, - orderBy: [ - { createdAt: 'desc' }, - { id: 'desc' } - ], - take: LIMIT, - skip: decodedCursor.offset - }) - nextCursor = logs.length === LIMIT ? nextCursorEncoded(decodedCursor, logs.length) : null - } - - return { - cursor: nextCursor, - entries: logs - } - }, failedInvoices: async (parent, args, { me, models }) => { if (!me) { throw new GqlAuthenticationError() @@ -459,17 +316,6 @@ const resolvers = { ORDER BY id DESC` } }, - Wallet: { - wallet: async (wallet) => { - return { - ...wallet.wallet, - __resolveType: generateTypeDefName(wallet.type) - } - } - }, - WalletDetails: { - __resolveType: wallet => wallet.__resolveType - }, InvoiceOrDirect: { __resolveType: invoiceOrDirect => invoiceOrDirect.__resolveType }, @@ -534,43 +380,6 @@ const resolvers = { return true }, - setWalletPriority: async (parent, { id, priority }, { me, models }) => { - if (!me) { - throw new GqlAuthenticationError() - } - - await models.wallet.update({ where: { userId: me.id, id: Number(id) }, data: { priority } }) - - return true - }, - removeWallet: async (parent, { id }, { me, models }) => { - if (!me) { - throw new GqlAuthenticationError() - } - - const wallet = await models.wallet.findUnique({ where: { userId: me.id, id: Number(id) } }) - if (!wallet) { - throw new GqlInputError('wallet not found') - } - - const logger = walletLogger({ wallet, models }) - await models.wallet.delete({ where: { userId: me.id, id: Number(id) } }) - - if (canReceive({ def: getWalletByType(wallet.type), config: wallet.wallet })) { - logger.info('details for receiving deleted') - } - - return true - }, - deleteWalletLogs: async (parent, { wallet }, { me, models }) => { - if (!me) { - throw new GqlAuthenticationError() - } - - await models.walletLog.deleteMany({ where: { userId: me.id, wallet } }) - - return true - }, buyCredits: async (parent, { credits }, { me, models, lnd }) => { return await performPaidAction('BUY_CREDITS', { credits }, { models, me, lnd }) } @@ -736,205 +545,9 @@ const resolvers = { } } -export default injectResolvers(resolvers) +export default resolvers -const logContextFromBolt11 = async (bolt11) => { - const decoded = await parsePaymentRequest({ request: bolt11 }) - return { - bolt11, - amount: formatMsats(decoded.mtokens), - payment_hash: decoded.id, - created_at: decoded.created_at, - expires_at: decoded.expires_at, - description: decoded.description - } -} - -export const walletLogger = ({ wallet, models, me }) => { - // no-op logger if no wallet or user provided - if (!wallet && !me) { - return { - ok: () => {}, - info: () => {}, - error: () => {}, - warn: () => {} - } - } - - // server implementation of wallet logger interface on client - const log = (level) => async (message, ctx = {}) => { - try { - let { invoiceId, withdrawalId, ...context } = ctx - - if (context.bolt11) { - // automatically populate context from bolt11 to avoid duplicating this code - context = { - ...context, - ...await logContextFromBolt11(context.bolt11) - } - } - - await models.walletLog.create({ - data: { - userId: wallet?.userId ?? me.id, - // system logs have no wallet - wallet: wallet?.type, - level, - message, - context, - invoiceId, - withdrawalId - } - }) - } catch (err) { - console.error('error creating wallet log:', err) - } - } - - return { - ok: (message, context) => log('SUCCESS')(message, context), - info: (message, context) => log('INFO')(message, context), - error: (message, context) => log('ERROR')(message, context), - warn: (message, context) => log('WARN')(message, context) - } -} - -async function upsertWallet ( - { wallet, walletDef, testCreateInvoice }, { settings, data, vaultEntries }, { logger, me, models }) { - if (!me) { - throw new GqlAuthenticationError() - } - assertApiKeyNotPermitted({ me }) - - if (testCreateInvoice) { - try { - const pr = await testCreateInvoice(data) - if (!pr || typeof pr !== 'string' || !pr.startsWith('lnbc')) { - throw new GqlInputError('not a valid payment request') - } - } catch (err) { - const message = 'failed to create test invoice: ' + (err.message || err.toString?.()) - logger.error(message) - throw new GqlInputError(message) - } - } - - const { id, enabled, priority, ...recvConfig } = data - - const txs = [] - - if (id) { - const oldVaultEntries = await models.vaultEntry.findMany({ where: { userId: me.id, walletId: Number(id) } }) - - // createMany is the set difference of the new - old - // deleteMany is the set difference of the old - new - // updateMany is the intersection of the old and new - const difference = (a = [], b = [], key = 'key') => a.filter(x => !b.find(y => y[key] === x[key])) - const intersectionMerge = (a = [], b = [], key = 'key') => a.filter(x => b.find(y => y[key] === x[key])) - .map(x => ({ [key]: x[key], ...b.find(y => y[key] === x[key]) })) - - txs.push( - models.wallet.update({ - where: { id: Number(id), userId: me.id }, - data: { - enabled, - priority, - // client only wallets have no receive config and thus don't have their own table - ...(Object.keys(recvConfig).length > 0 - ? { - [wallet.field]: { - upsert: { - create: recvConfig, - update: recvConfig - } - } - } - : {}), - ...(vaultEntries - ? { - vaultEntries: { - deleteMany: difference(oldVaultEntries, vaultEntries, 'key').map(({ key }) => ({ - userId: me.id, key - })), - create: difference(vaultEntries, oldVaultEntries, 'key').map(({ key, iv, value }) => ({ - key, iv, value, userId: me.id - })), - update: intersectionMerge(oldVaultEntries, vaultEntries, 'key').map(({ key, iv, value }) => ({ - where: { userId_key: { userId: me.id, key } }, - data: { value, iv } - })) - } - } - : {}) - - }, - include: { - vaultEntries: true - } - }) - ) - } else { - txs.push( - models.wallet.create({ - include: { - vaultEntries: true - }, - data: { - enabled, - priority, - userId: me.id, - type: wallet.type, - // client only wallets have no receive config and thus don't have their own table - ...(Object.keys(recvConfig).length > 0 ? { [wallet.field]: { create: recvConfig } } : {}), - ...(vaultEntries - ? { - vaultEntries: { - createMany: { - data: vaultEntries?.map(({ key, iv, value }) => ({ key, iv, value, userId: me.id })) - } - } - } - : {}) - } - }) - ) - } - - if (settings) { - txs.push( - models.user.update({ - where: { id: me.id }, - data: settings - }) - ) - } - - if (canReceive({ def: walletDef, config: recvConfig })) { - txs.push( - models.walletLog.createMany({ - data: { - userId: me.id, - wallet: wallet.type, - level: 'SUCCESS', - message: id ? 'details for receiving updated' : 'details for receiving saved' - } - }), - models.walletLog.create({ - data: { - userId: me.id, - wallet: wallet.type, - level: enabled ? 'SUCCESS' : 'INFO', - message: enabled ? 'receiving enabled' : 'receiving disabled' - } - }) - ) - } - - const [upsertedWallet] = await models.$transaction(txs) - return upsertedWallet -} - -export async function createWithdrawal (parent, { invoice, maxFee }, { me, models, lnd, headers, wallet, logger }) { +export async function createWithdrawal (parent, { invoice, maxFee }, { me, models, lnd, headers, protocol, logger }) { assertApiKeyNotPermitted({ me }) await validateSchema(withdrawlSchema, { invoice, maxFee }) await assertGofacYourself({ models, headers }) @@ -984,7 +597,7 @@ export async function createWithdrawal (parent, { invoice, maxFee }, { me, model throw new GqlInputError('SN cannot pay an invoice that SN is proxying') } - return await performPayingAction({ bolt11: invoice, maxFee, walletId: wallet?.id }, { me, models, lnd }) + return await performPayingAction({ bolt11: invoice, maxFee, protocolId: protocol?.id }, { me, models, lnd }) } async function sendToLnAddr (parent, { addr, amount, maxFee, comment, ...payer }, diff --git a/api/typeDefs/index.js b/api/typeDefs/index.js index eb4e1e42..29ed7dda 100644 --- a/api/typeDefs/index.js +++ b/api/typeDefs/index.js @@ -18,7 +18,6 @@ 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 { @@ -39,4 +38,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, vault] + sub, upload, growth, rewards, referrals, price, admin, blockHeight, chainFee, paidAction] diff --git a/api/typeDefs/user.js b/api/typeDefs/user.js index 1e715591..8c768b3a 100644 --- a/api/typeDefs/user.js +++ b/api/typeDefs/user.js @@ -124,9 +124,6 @@ export default gql` zapUndos: Int wildWestMode: Boolean! withdrawMaxFeeDefault: Int! - proxyReceive: Boolean - receiveCreditsBelowSats: Int! - sendCreditsBelowSats: Int! } type AuthMethods { @@ -157,6 +154,7 @@ export default gql` upvotePopover: Boolean! hasInvites: Boolean! apiKeyEnabled: Boolean! + showPassphrase: Boolean! """ mirrors SettingsInput @@ -203,14 +201,8 @@ export default gql` wildWestMode: Boolean! withdrawMaxFeeDefault: Int! autoWithdrawThreshold: Int - autoWithdrawMaxFeePercent: Float - autoWithdrawMaxFeeTotal: Int vaultKeyHash: String walletsUpdatedAt: Date - proxyReceive: Boolean - directReceive: Boolean @deprecated - receiveCreditsBelowSats: Int! - sendCreditsBelowSats: Int! } type UserOptional { diff --git a/api/typeDefs/vault.js b/api/typeDefs/vault.js deleted file mode 100644 index 3e2860a3..00000000 --- a/api/typeDefs/vault.js +++ /dev/null @@ -1,28 +0,0 @@ -import { gql } from 'graphql-tag' - -export default gql` - type VaultEntry { - id: ID! - key: String! - iv: String! - value: String! - createdAt: Date! - updatedAt: Date! - } - - input VaultEntryInput { - key: String! - iv: String! - value: String! - walletId: ID - } - - extend type Query { - getVaultEntries: [VaultEntry!]! - } - - extend type Mutation { - clearVault: Boolean - updateVaultKey(entries: [VaultEntryInput!]!, hash: String!): Boolean - } -` diff --git a/api/typeDefs/wallet.js b/api/typeDefs/wallet.js index b003ba42..885c6b49 100644 --- a/api/typeDefs/wallet.js +++ b/api/typeDefs/wallet.js @@ -1,66 +1,6 @@ import { gql } from 'graphql-tag' -import { fieldToGqlArg, fieldToGqlArgOptional, generateResolverName, generateTypeDefName } from '@/wallets/graphql' -import { isServerField } from '@/wallets/common' -import walletDefs from '@/wallets/server' -function injectTypeDefs (typeDefs) { - const injected = [rawTypeDefs(), mutationTypeDefs()] - return `${typeDefs}\n\n${injected.join('\n\n')}\n` -} - -function mutationTypeDefs () { - console.group('injected GraphQL mutations:') - - const typeDefs = walletDefs.map((w) => { - let args = 'id: ID, ' - const serverFields = w.fields - .filter(isServerField) - .map(fieldToGqlArgOptional) - if (serverFields.length > 0) args += serverFields.join(', ') + ',' - args += 'enabled: Boolean, priority: Int, vaultEntries: [VaultEntryInput!], settings: AutowithdrawSettings, validateLightning: Boolean' - const resolverName = generateResolverName(w.walletField) - const typeDef = `${resolverName}(${args}): Wallet` - console.log(typeDef) - return typeDef - }) - - console.groupEnd() - - return `extend type Mutation {\n${typeDefs.join('\n')}\n}` -} - -function rawTypeDefs () { - console.group('injected GraphQL type defs:') - - const typeDefs = walletDefs.map((w) => { - 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) - return typeDef - }) - - let union = 'union WalletDetails = ' - union += walletDefs.map((w) => { - const typeDefName = generateTypeDefName(w.walletType) - return typeDefName - }).join(' | ') - console.log(union) - - console.groupEnd() - - return typeDefs.join('\n\n') + union -} - -const typeDefs = ` +const typeDefs = gql` extend type Query { invoice(id: ID!): Invoice! withdrawl(id: ID!): Withdrawl! @@ -68,8 +8,10 @@ const typeDefs = ` numBolt11s: Int! connectAddress: String! walletHistory(cursor: String, inc: String): History - wallets: [Wallet!]! - walletLogs(type: String, from: String, to: String, cursor: String): WalletLog! + wallets: [WalletOrTemplate!]! + wallet(id: ID, name: String): WalletOrTemplate + walletSettings: WalletSettings! + walletLogs(protocolId: Int, cursor: String): WalletLogs! failedInvoices: [Invoice!]! } @@ -79,9 +21,30 @@ const typeDefs = ` cancelInvoice(hash: String!, hmac: String, userCancel: Boolean): Invoice! dropBolt11(hash: String!): Boolean removeWallet(id: ID!): Boolean - deleteWalletLogs(wallet: String): Boolean - setWalletPriority(id: ID!, priority: Int!): Boolean + deleteWalletLogs(protocolId: Int): Boolean + setWalletPriorities(priorities: [WalletPriorityUpdate!]!): Boolean buyCredits(credits: Int!): BuyCreditsPaidAction! + + upsertWalletSendLNbits(walletId: ID, templateName: ID, enabled: Boolean!, url: String!, apiKey: VaultEntryInput!): WalletSendLNbits! + upsertWalletRecvLNbits(walletId: ID, templateName: ID, enabled: Boolean!, networkTests: Boolean, url: String!, apiKey: String!): WalletRecvLNbits! + upsertWalletSendPhoenixd(walletId: ID, templateName: ID, enabled: Boolean!, url: String!, apiKey: VaultEntryInput!): WalletSendPhoenixd! + upsertWalletRecvPhoenixd(walletId: ID, templateName: ID, enabled: Boolean!, networkTests: Boolean, url: String!, apiKey: String!): WalletRecvPhoenixd! + upsertWalletSendBlink(walletId: ID, templateName: ID, enabled: Boolean!, currency: VaultEntryInput!, apiKey: VaultEntryInput!): WalletSendBlink! + upsertWalletRecvBlink(walletId: ID, templateName: ID, enabled: Boolean!, networkTests: Boolean, currency: String!, apiKey: String!): WalletRecvBlink! + upsertWalletRecvLightningAddress(walletId: ID, templateName: ID, enabled: Boolean!, networkTests: Boolean, address: String!): WalletRecvLightningAddress! + upsertWalletSendNWC(walletId: ID, templateName: ID, enabled: Boolean!, url: VaultEntryInput!): WalletSendNWC! + upsertWalletRecvNWC(walletId: ID, templateName: ID, enabled: Boolean!, networkTests: Boolean, url: String!): WalletRecvNWC! + upsertWalletRecvCLNRest(walletId: ID, templateName: ID, enabled: Boolean!, networkTests: Boolean, socket: String!, rune: String!, cert: String): WalletRecvCLNRest! + upsertWalletRecvLNDGRPC(walletId: ID, templateName: ID, enabled: Boolean!, networkTests: Boolean, socket: String!, macaroon: String!, cert: String): WalletRecvLNDGRPC! + upsertWalletSendLNC(walletId: ID, templateName: ID, enabled: Boolean!, pairingPhrase: VaultEntryInput!, localKey: VaultEntryInput!, remoteKey: VaultEntryInput!, serverHost: VaultEntryInput!): WalletSendLNC! + upsertWalletSendWebLN(walletId: ID, templateName: ID, enabled: Boolean!): WalletSendWebLN! + removeWalletProtocol(id: ID!): Boolean + updateWalletEncryption(keyHash: String!, wallets: [WalletEncryptionUpdate!]!): Boolean + updateKeyHash(keyHash: String!): Boolean + resetWallets(newKeyHash: String!): Boolean + disablePassphraseExport: Boolean + setWalletSettings(settings: WalletSettingsInput!): Boolean + addWalletLog(protocolId: Int!, level: String!, message: String!, timestamp: Date!, invoiceId: Int): Boolean } type BuyCreditsResult { @@ -92,15 +55,155 @@ const typeDefs = ` id: ID! } + union WalletOrTemplate = Wallet | WalletTemplate + + enum WalletStatus { + OK + WARNING + ERROR + DISABLED + } + type Wallet { id: ID! - createdAt: Date! - updatedAt: Date! - type: String! - enabled: Boolean! + name: String! priority: Int! - wallet: WalletDetails! - vaultEntries: [VaultEntry!]! + template: WalletTemplate! + protocols: [WalletProtocol!]! + send: WalletStatus! + receive: WalletStatus! + } + + type WalletTemplate { + name: ID! + protocols: [WalletProtocolTemplate!]! + send: WalletStatus! + receive: WalletStatus! + } + + type WalletProtocol { + id: ID! + name: String! + send: Boolean! + enabled: Boolean! + config: WalletProtocolConfig! + status: WalletStatus! + } + + type WalletProtocolTemplate { + id: ID! + name: String! + send: Boolean! + } + + union WalletProtocolConfig = + | WalletSendNWC + | WalletSendLNbits + | WalletSendPhoenixd + | WalletSendBlink + | WalletSendWebLN + | WalletSendLNC + | WalletRecvNWC + | WalletRecvLNbits + | WalletRecvPhoenixd + | WalletRecvBlink + | WalletRecvLightningAddress + | WalletRecvCLNRest + | WalletRecvLNDGRPC + + type WalletSettings { + receiveCreditsBelowSats: Int! + sendCreditsBelowSats: Int! + autoWithdrawThreshold: Int + autoWithdrawMaxFeePercent: Float + autoWithdrawMaxFeeTotal: Int + proxyReceive: Boolean! + } + + input WalletSettingsInput { + receiveCreditsBelowSats: Int! + sendCreditsBelowSats: Int! + autoWithdrawThreshold: Int! + autoWithdrawMaxFeePercent: Float! + autoWithdrawMaxFeeTotal: Int! + proxyReceive: Boolean! + } + + type WalletSendNWC { + id: ID! + url: VaultEntry! + } + + type WalletSendLNbits { + id: ID! + url: String! + apiKey: VaultEntry! + } + + type WalletSendPhoenixd { + id: ID! + url: String! + apiKey: VaultEntry! + } + + type WalletSendBlink { + id: ID! + currency: VaultEntry! + apiKey: VaultEntry! + } + + type WalletSendWebLN { + id: ID! + } + + type WalletSendLNC { + id: ID! + pairingPhrase: VaultEntry! + localKey: VaultEntry! + remoteKey: VaultEntry! + serverHost: VaultEntry! + } + + type WalletRecvNWC { + id: ID! + url: String! + } + + type WalletRecvLNbits { + id: ID! + url: String! + apiKey: String! + } + + type WalletRecvPhoenixd { + id: ID! + url: String! + apiKey: String! + } + + type WalletRecvBlink { + id: ID! + currency: String! + apiKey: String! + } + + type WalletRecvLightningAddress { + id: ID! + address: String! + } + + type WalletRecvCLNRest { + id: ID! + socket: String! + rune: String! + cert: String + } + + type WalletRecvLNDGRPC { + id: ID! + socket: String! + macaroon: String! + cert: String } input AutowithdrawSettings { @@ -109,6 +212,22 @@ const typeDefs = ` autoWithdrawMaxFeeTotal: Int! } + input WalletEncryptionUpdate { + id: ID! + protocols: [WalletEncryptionUpdateProtocol!]! + } + + input WalletEncryptionUpdateProtocol { + name: String! + send: Boolean! + config: JSONObject! + } + + input WalletPriorityUpdate { + id: ID! + priority: Int! + } + type Invoice implements InvoiceOrDirect { id: ID! createdAt: Date! @@ -183,7 +302,7 @@ const typeDefs = ` cursor: String } - type WalletLog { + type WalletLogs { entries: [WalletLogEntry!]! cursor: String } @@ -191,11 +310,25 @@ const typeDefs = ` type WalletLogEntry { id: ID! createdAt: Date! - wallet: ID + wallet: Wallet + protocol: WalletProtocol level: String! message: String! context: JSONObject } -` -export default gql`${injectTypeDefs(typeDefs)}` + type VaultEntry { + id: ID! + iv: String! + value: String! + createdAt: Date! + updatedAt: Date! + } + + input VaultEntryInput { + iv: String! + value: String! + keyHash: String! + } +` +export default typeDefs diff --git a/components/autowithdraw-shared.js b/components/autowithdraw-shared.js deleted file mode 100644 index d0d837b7..00000000 --- a/components/autowithdraw-shared.js +++ /dev/null @@ -1,76 +0,0 @@ -import { InputGroup } from 'react-bootstrap' -import { Input } from './form' -import { useMe } from './me' -import { useEffect, useState } from 'react' -import { isNumber } from '@/lib/format' -import Link from 'next/link' - -function autoWithdrawThreshold ({ me }) { - return isNumber(me?.privates?.autoWithdrawThreshold) ? me?.privates?.autoWithdrawThreshold : 10000 -} - -export function autowithdrawInitial ({ me }) { - return { - autoWithdrawThreshold: autoWithdrawThreshold({ me }), - autoWithdrawMaxFeePercent: isNumber(me?.privates?.autoWithdrawMaxFeePercent) ? me?.privates?.autoWithdrawMaxFeePercent : 1, - autoWithdrawMaxFeeTotal: isNumber(me?.privates?.autoWithdrawMaxFeeTotal) ? me?.privates?.autoWithdrawMaxFeeTotal : 1 - } -} - -export function AutowithdrawSettings () { - const { me } = useMe() - const threshold = autoWithdrawThreshold({ me }) - - const [sendThreshold, setSendThreshold] = useState(Math.max(Math.floor(threshold / 10), 1)) - - useEffect(() => { - setSendThreshold(Math.max(Math.floor(threshold / 10), 1)) - }, [autoWithdrawThreshold]) - - return ( - <> -
-
-

desired balance

-
applies globally to all autowithdraw methods
- { - const value = e.target.value - setSendThreshold(Math.max(Math.floor(value / 10), 1)) - }} - hint={isNumber(sendThreshold) ? `will attempt auto-withdraw when your balance exceeds ${sendThreshold * 11} sats` : undefined} - append={sats} - required - /> -

network fees

-
- we'll use whichever setting is higher during{' '} - pathfinding - -
- %} - required - /> - sats} - required - /> -
-
- - - ) -} diff --git a/components/banners.js b/components/banners.js index c4edbf0f..8f1523ab 100644 --- a/components/banners.js +++ b/components/banners.js @@ -5,7 +5,6 @@ import { useMe } from '@/components/me' import { useMutation } from '@apollo/client' import { WELCOME_BANNER_MUTATION } from '@/fragments/users' import { useToast } from '@/components/toast' -import Link from 'next/link' export function WelcomeBanner ({ Banner }) { const { me } = useMe() @@ -100,22 +99,6 @@ export function MadnessBanner ({ handleClose }) { ) } -export function WalletSecurityBanner ({ isActive }) { - return ( - - - Gunslingin' Safety Tips - -

- Listen up, pardner! Put a limit on yer spendin' wallet or hook up a wallet that's only for Stacker News. It'll keep them varmints from cleanin' out yer whole goldmine if they rustle up yer wallet. -

-

- Your spending wallet's credentials are never sent to our servers in plain text. To sync across devices, enable device sync in your settings. -

-
- ) -} - export function AuthBanner () { return ( diff --git a/components/form.js b/components/form.js index 0a4c70ea..44c1c5e1 100644 --- a/components/form.js +++ b/components/form.js @@ -34,12 +34,9 @@ import Info from './info' import { useMe } from './me' import classNames from 'classnames' import Clipboard from '@/svgs/clipboard-line.svg' -import QrIcon from '@/svgs/qr-code-line.svg' import QrScanIcon from '@/svgs/qr-scan-line.svg' import { useShowModal } from './modal' -import { QRCodeSVG } from 'qrcode.react' import dynamic from 'next/dynamic' -import { qrImageSettings } from './qr' import { useIsClient } from './use-client' import PageLoading from './page-loading' @@ -78,7 +75,7 @@ export function SubmitButton ({ ) } -function CopyButton ({ value, icon, ...props }) { +export function CopyButton ({ value, icon, ...props }) { const toaster = useToast() const [copied, setCopied] = useState(false) @@ -1333,33 +1330,6 @@ function PasswordHider ({ onClick, showPass }) { ) } -function QrPassword ({ value }) { - const showModal = useShowModal() - const toaster = useToast() - - const showQr = useCallback(() => { - showModal(close => ( -
-

Import this passphrase into another device by navigating to device sync settings and scanning this QR code

-
- -
-
- )) - }, [toaster, value, showModal]) - - return ( - <> - - - - - ) -} - function PasswordScanner ({ onScan, text }) { const showModal = useShowModal() const toaster = useToast() @@ -1422,12 +1392,12 @@ export function PasswordInput ({ newPass, qr, copy, readOnly, append, value: ini {copy && ( )} - {qr && (readOnly - ? - : helpers.setValue(v)} - />)} + {qr && ( + helpers.setValue(v)} + /> + )} {append} ) diff --git a/components/invoice.js b/components/invoice.js index bedf285c..cdc984ed 100644 --- a/components/invoice.js +++ b/components/invoice.js @@ -6,9 +6,9 @@ import { CompactLongCountdown } from './countdown' import PayerData from './payer-data' import Bolt11Info from './bolt11-info' import { useQuery } from '@apollo/client' -import { INVOICE } from '@/fragments/wallet' +import { INVOICE } from '@/fragments/invoice' import { FAST_POLL_INTERVAL, SSR } from '@/lib/constants' -import { WalletConfigurationError, WalletPaymentAggregateError } from '@/wallets/errors' +import { WalletConfigurationError, WalletPaymentAggregateError } from '@/wallets/client/errors' import ItemJob from './item-job' import Item from './item' import { CommentFlat } from './comment' diff --git a/components/item-act.js b/components/item-act.js index 27172d3b..87189b4c 100644 --- a/components/item-act.js +++ b/components/item-act.js @@ -12,7 +12,7 @@ import { usePaidMutation } from './use-paid-mutation' import { ACT_MUTATION } from '@/fragments/paidAction' import { meAnonSats } from '@/lib/apollo' import { BoostItemInput } from './adv-post-form' -import { useSendWallets } from '@/wallets/index' +import { useHasSendWallet } from '@/wallets/client/hooks' import { useAnimation } from '@/components/animation' const defaultTips = [100, 1000, 10_000, 100_000] @@ -88,7 +88,7 @@ function BoostForm ({ step, onSubmit, children, item, oValue, inputRef, act = 'B export default function ItemAct ({ onClose, item, act = 'TIP', step, children, abortSignal }) { const inputRef = useRef(null) const { me } = useMe() - const wallets = useSendWallets() + const hasSendWallet = useHasSendWallet() const [oValue, setOValue] = useState() useEffect(() => { @@ -116,7 +116,7 @@ export default function ItemAct ({ onClose, item, act = 'TIP', step, children, a if (!me) setItemMeAnonSats({ id: item.id, amount }) } - const closeImmediately = wallets.length > 0 || me?.privates?.sats > Number(amount) + const closeImmediately = hasSendWallet || me?.privates?.sats > Number(amount) if (closeImmediately) { onPaid() } @@ -126,7 +126,7 @@ export default function ItemAct ({ onClose, item, act = 'TIP', step, children, a id: item.id, sats: Number(amount), act, - hasSendWallet: wallets.length > 0 + hasSendWallet }, optimisticResponse: me ? { @@ -143,7 +143,7 @@ export default function ItemAct ({ onClose, item, act = 'TIP', step, children, a }) if (error) throw error addCustomTip(Number(amount)) - }, [me, actor, wallets.length, act, item.id, onClose, abortSignal, animate]) + }, [me, actor, hasSendWallet, act, item.id, onClose, abortSignal, animate]) return act === 'BOOST' ? {children} @@ -263,13 +263,13 @@ export function useAct ({ query = ACT_MUTATION, ...options } = {}) { // because the mutation name we use varies, // we need to extract the result/invoice from the response const getPaidActionResult = data => Object.values(data)[0] - const wallets = useSendWallets() + const hasSendWallet = useHasSendWallet() const [act] = usePaidMutation(query, { waitFor: inv => // if we have attached wallets, we might be paying a wrapped invoice in which case we need to make sure // we don't prematurely consider the payment as successful (important for receiver fallbacks) - wallets.length > 0 + hasSendWallet ? inv?.actionState === 'PAID' : inv?.satsReceived > 0, ...options, @@ -298,7 +298,7 @@ export function useAct ({ query = ACT_MUTATION, ...options } = {}) { } export function useZap () { - const wallets = useSendWallets() + const hasSendWallet = useHasSendWallet() const act = useAct() const animate = useAnimation() const toaster = useToast() @@ -309,14 +309,14 @@ export function useZap () { // add current sats to next tip since idempotent zaps use desired total zap not difference const sats = nextTip(meSats, { ...me?.privates }) - const variables = { id: item.id, sats, act: 'TIP', hasSendWallet: wallets.length > 0 } + const variables = { id: item.id, sats, act: 'TIP', hasSendWallet } const optimisticResponse = { act: { __typename: 'ItemActPaidAction', result: { path: item.path, ...variables } } } try { await abortSignal.pause({ me, amount: sats }) animate() // batch zaps if wallet is enabled or using fee credits so they can be executed serially in a single request - const { error } = await act({ variables, optimisticResponse, context: { batch: wallets.length > 0 || me?.privates?.sats > sats } }) + const { error } = await act({ variables, optimisticResponse, context: { batch: hasSendWallet || me?.privates?.sats > sats } }) if (error) throw error } catch (error) { if (error instanceof ActCanceledError) { @@ -327,7 +327,7 @@ export function useZap () { // but right now this toast is noisy for optimistic zaps console.error(error) } - }, [act, toaster, animate, wallets]) + }, [act, toaster, animate, hasSendWallet]) } export class ActCanceledError extends Error { diff --git a/components/log-message.js b/components/log-message.js deleted file mode 100644 index 73f76263..00000000 --- a/components/log-message.js +++ /dev/null @@ -1,62 +0,0 @@ -import { timeSince } from '@/lib/time' -import styles from '@/styles/log.module.css' -import { Fragment, useState } from 'react' - -export default function LogMessage ({ showWallet, wallet, level, message, context, ts }) { - const [show, setShow] = useState(false) - - let className - switch (level.toLowerCase()) { - case 'ok': - case 'success': - level = 'ok' - className = 'text-success'; break - case 'error': - className = 'text-danger'; break - case 'warn': - className = 'text-warning'; break - default: - className = 'text-info' - } - - const filtered = context - ? Object.keys(context) - .filter(key => !['send', 'recv', 'status'].includes(key)) - .reduce((obj, key) => { - obj[key] = context[key] - return obj - }, {}) - : {} - - const hasContext = context && Object.keys(filtered).length > 0 - - const handleClick = () => { - if (hasContext) { setShow(show => !show) } - } - - const style = hasContext ? { cursor: 'pointer' } : { cursor: 'inherit' } - const indicator = hasContext ? (show ? '-' : '+') : <> - - return ( - <> - - {timeSince(new Date(ts))} - {showWallet ? [{wallet}] : } - {level} - {message} - {indicator} - - {show && hasContext && Object.entries(filtered) - .map(([key, value], i) => { - const last = i === Object.keys(filtered).length - 1 - return ( - - - {key} - {value} - - ) - })} - - ) -} diff --git a/components/modal.js b/components/modal.js index f5039ebc..151674cf 100644 --- a/components/modal.js +++ b/components/modal.js @@ -4,6 +4,12 @@ import BackArrow from '@/svgs/arrow-left-line.svg' import { useRouter } from 'next/router' import ActionDropdown from './action-dropdown' +export class ModalClosedError extends Error { + constructor () { + super('modal closed') + } +} + export const ShowModalContext = createContext(() => null) export function ShowModalProvider ({ children }) { diff --git a/components/nav/common.js b/components/nav/common.js index 3f9bbb61..80cf1335 100644 --- a/components/nav/common.js +++ b/components/nav/common.js @@ -19,8 +19,8 @@ import SearchIcon from '../../svgs/search-line.svg' import classNames from 'classnames' import SnIcon from '@/svgs/sn.svg' import { useHasNewNotes } from '../use-has-new-notes' -import { useWallets } from '@/wallets/index' -import { useWalletIndicator } from '@/wallets/indicator' +// import { useWallets } from '@/wallets/client/hooks' +import { useWalletIndicator } from '@/wallets/client/hooks' import SwitchAccountList, { nextAccount, useAccounts } from '@/components/account' import { useShowModal } from '@/components/modal' import { numWithUnits } from '@/lib/format' @@ -293,7 +293,7 @@ export default function LoginButton () { function LogoutObstacle ({ onClose }) { const { registration: swRegistration, togglePushSubscription } = useServiceWorker() - const { removeLocalWallets } = useWallets() + // const { removeLocalWallets } = useWallets() const router = useRouter() return ( @@ -324,8 +324,6 @@ function LogoutObstacle ({ onClose }) { await togglePushSubscription().catch(console.error) } - removeLocalWallets() - await signOut({ callbackUrl: '/' }) }} > diff --git a/components/nav/mobile/offcanvas.js b/components/nav/mobile/offcanvas.js index 219e2917..09e60219 100644 --- a/components/nav/mobile/offcanvas.js +++ b/components/nav/mobile/offcanvas.js @@ -7,7 +7,7 @@ import AnonIcon from '@/svgs/spy-fill.svg' import styles from './footer.module.css' import canvasStyles from './offcanvas.module.css' import classNames from 'classnames' -import { useWalletIndicator } from '@/wallets/indicator' +import { useWalletIndicator } from '@/wallets/client/hooks' export default function OffCanvas ({ me, dropNavKey }) { const [show, setShow] = useState(false) diff --git a/components/pay-bounty.js b/components/pay-bounty.js index d960c316..b4d079e5 100644 --- a/components/pay-bounty.js +++ b/components/pay-bounty.js @@ -8,7 +8,7 @@ import { useRoot } from './root' import { ActCanceledError, useAct } from './item-act' import { useAnimation } from '@/components/animation' import { useToast } from './toast' -import { useSendWallets } from '@/wallets/index' +import { useHasSendWallet } from '@/wallets/client/hooks' import { Form, SubmitButton } from './form' export const payBountyCacheMods = { @@ -50,9 +50,9 @@ export default function PayBounty ({ children, item }) { const root = useRoot() const animate = useAnimation() const toaster = useToast() - const wallets = useSendWallets() + const hasSendWallet = useHasSendWallet() - const variables = { id: item.id, sats: root.bounty, act: 'TIP', hasSendWallet: wallets.length > 0 } + const variables = { id: item.id, sats: root.bounty, act: 'TIP', hasSendWallet } const act = useAct({ variables, optimisticResponse: { act: { __typename: 'ItemActPaidAction', result: { ...variables, path: item.path } } }, diff --git a/components/use-indexeddb.js b/components/use-indexeddb.js index 0afeeeeb..6a652b8a 100644 --- a/components/use-indexeddb.js +++ b/components/use-indexeddb.js @@ -1,300 +1,165 @@ -import { useState, useEffect, useCallback, useRef } from 'react' +import { useMe } from '@/components/me' +import { useCallback, useMemo } from 'react' -export function getDbName (userId, name) { - return `app:storage:${userId ?? ''}${name ? `:${name}` : ''}` -} +const VERSION = 2 -const DEFAULT_OPTIONS = { keyPath: 'id', autoIncrement: true } -const DEFAULT_INDICES = [] -const DEFAULT_VERSION = 1 +export function useIndexedDB (dbName) { + const { me } = useMe() + if (!dbName) dbName = me?.id ? `app:storage:${me.id}` : 'app:storage' -function useIndexedDB ({ dbName, storeName, options = DEFAULT_OPTIONS, indices = DEFAULT_INDICES, version = DEFAULT_VERSION }) { - const [db, setDb] = useState(null) - const [error, setError] = useState(null) - const [notSupported, setNotSupported] = useState(false) - const operationQueue = useRef([]) - - const handleError = useCallback((error) => { - console.error('IndexedDB error:', error) - setError(error) - }, []) - - const processQueue = useCallback((db) => { - if (!db) return + const set = useCallback(async (storeName, key, value) => { + const db = await _open(dbName, VERSION) try { - // try to run a noop to see if the db is ready - db.transaction(storeName) - while (operationQueue.current.length > 0) { - const operation = operationQueue.current.shift() - // if the db is the same as the one we're processing, run the operation - // else, we'll just clear the operation queue - // XXX this is a consquence of using a ref to store the queue and should be fixed - if (dbName === db.name) { - operation(db) - } - } - } catch (error) { - handleError(error) + return await _set(db, storeName, key, value) + } finally { + db.close() } - }, [dbName, storeName, handleError, operationQueue]) + }, [dbName]) - useEffect(() => { - let isMounted = true + const get = useCallback(async (storeName, key) => { + const db = await _open(dbName, VERSION) + + try { + return await _get(db, storeName, key) + } finally { + db.close() + } + }, [dbName]) + + const deleteDb = useCallback(async () => { + return await _delete(dbName) + }, [dbName]) + + const open = useCallback(async () => { + return await _open(dbName, VERSION) + }, [dbName]) + + return useMemo(() => ({ set, get, deleteDb, open }), [set, get, deleteDb, open]) +} + +async function _open (dbName, version = 1) { + return await new Promise((resolve, reject) => { + if (typeof window.indexedDB === 'undefined') { + return reject(new IndexedDBOpenError('IndexedDB unavailable')) + } + + const request = window.indexedDB.open(dbName, version) + + request.onupgradeneeded = (event) => { + try { + const db = event.target.result + if (!db.objectStoreNames.contains('vault')) db.createObjectStore('vault') + if (db.objectStoreNames.contains('wallet_logs')) db.deleteObjectStore('wallet_logs') + } catch (error) { + reject(new IndexedDBOpenError(`upgrade failed: ${error?.message}`)) + } + } + + request.onerror = (event) => { + reject(new IndexedDBOpenError(request.error?.message)) + } + + request.onsuccess = (event) => { + const db = request.result + resolve(db) + } + }) +} + +async function _set (db, storeName, key, value) { + return await new Promise((resolve, reject) => { let request try { - if (!window.indexedDB) { - console.log('IndexedDB is not supported') - setNotSupported(true) - return - } - - request = window.indexedDB.open(dbName, version) - - request.onerror = (event) => { - handleError(new Error('Error opening database')) - } - - request.onsuccess = (event) => { - if (isMounted) { - const database = event.target.result - database.onversionchange = () => { - database.close() - setDb(null) - handleError(new Error('Database is outdated, please reload the page')) - } - setDb(database) - processQueue(database) - } - } - - request.onupgradeneeded = (event) => { - const database = event.target.result - try { - const store = database.createObjectStore(storeName, options) - - indices.forEach(index => { - store.createIndex(index.name, index.keyPath, index.options) - }) - } catch (error) { - handleError(new Error('Error upgrading database: ' + error.message)) - } - } + request = db + .transaction(storeName, 'readwrite') + .objectStore(storeName) + .put(value, key) } catch (error) { - handleError(new Error('Error opening database: ' + error.message)) + return reject(new IndexedDBSetError(error?.message)) } - return () => { - isMounted = false - if (db) { - db.close() - } - } - }, [dbName, storeName, version, indices, options, handleError, processQueue]) - - const queueOperation = useCallback((operation) => { - if (notSupported) { - return Promise.reject(new Error('IndexedDB is not supported')) - } - if (error) { - return Promise.reject(new Error('Database error: ' + error.message)) + request.onerror = (event) => { + reject(new IndexedDBSetError(event.target?.error?.message)) } - return new Promise((resolve, reject) => { - const wrappedOperation = (db) => { - try { - const result = operation(db) - resolve(result) - } catch (error) { - reject(error) - } - } - - operationQueue.current.push(wrappedOperation) - processQueue(db) - }) - }, [processQueue, db, notSupported, error]) - - const add = useCallback((value) => { - return queueOperation((db) => { - return new Promise((resolve, reject) => { - const transaction = db.transaction(storeName, 'readwrite') - const store = transaction.objectStore(storeName) - const request = store.add(value) - - request.onerror = () => reject(new Error('Error adding data')) - request.onsuccess = () => resolve(request.result) - }) - }) - }, [queueOperation, storeName]) - - const get = useCallback((key) => { - return queueOperation((db) => { - return new Promise((resolve, reject) => { - const transaction = db.transaction(storeName, 'readonly') - const store = transaction.objectStore(storeName) - const request = store.get(key) - - request.onerror = () => reject(new Error('Error getting data')) - request.onsuccess = () => resolve(request.result ? request.result : undefined) - }) - }) - }, [queueOperation, storeName]) - - const getAll = useCallback(() => { - return queueOperation((db) => { - return new Promise((resolve, reject) => { - const transaction = db.transaction(storeName, 'readonly') - const store = transaction.objectStore(storeName) - const request = store.getAll() - - request.onerror = () => reject(new Error('Error getting all data')) - request.onsuccess = () => resolve(request.result) - }) - }) - }, [queueOperation, storeName]) - - const set = useCallback((key, value) => { - return queueOperation((db) => { - return new Promise((resolve, reject) => { - const transaction = db.transaction(storeName, 'readwrite') - const store = transaction.objectStore(storeName) - const request = store.put(value, key) - - request.onerror = () => reject(new Error('Error setting data')) - request.onsuccess = () => resolve(request.result) - }) - }) - }, [queueOperation, storeName]) - - const remove = useCallback((key) => { - return queueOperation((db) => { - return new Promise((resolve, reject) => { - const transaction = db.transaction(storeName, 'readwrite') - const store = transaction.objectStore(storeName) - const request = store.delete(key) - - request.onerror = () => reject(new Error('Error removing data')) - request.onsuccess = () => resolve() - }) - }) - }, [queueOperation, storeName]) - - const clear = useCallback((indexName = null, query = null) => { - return queueOperation((db) => { - return new Promise((resolve, reject) => { - const transaction = db.transaction(storeName, 'readwrite') - const store = transaction.objectStore(storeName) - - if (!query) { - // Clear all data if no query is provided - const request = store.clear() - request.onerror = () => reject(new Error('Error clearing all data')) - request.onsuccess = () => resolve() - } else { - // Clear data based on the query - const index = indexName ? store.index(indexName) : store - const request = index.openCursor(query) - let deletedCount = 0 - - request.onerror = () => reject(new Error('Error clearing data based on query')) - request.onsuccess = (event) => { - const cursor = event.target.result - if (cursor) { - const deleteRequest = cursor.delete() - deleteRequest.onerror = () => reject(new Error('Error deleting item')) - deleteRequest.onsuccess = () => { - deletedCount++ - cursor.continue() - } - } else { - resolve(deletedCount) - } - } - } - }) - }) - }, [queueOperation, storeName]) - - const getByIndex = useCallback((indexName, key) => { - return queueOperation((db) => { - return new Promise((resolve, reject) => { - const transaction = db.transaction(storeName, 'readonly') - const store = transaction.objectStore(storeName) - const index = store.index(indexName) - const request = index.get(key) - - request.onerror = () => reject(new Error('Error getting data by index')) - request.onsuccess = () => resolve(request.result) - }) - }) - }, [queueOperation, storeName]) - - const getAllByIndex = useCallback((indexName, query, direction = 'next', limit = Infinity) => { - return queueOperation((db) => { - return new Promise((resolve, reject) => { - const transaction = db.transaction(storeName, 'readonly') - const store = transaction.objectStore(storeName) - const index = store.index(indexName) - const request = index.openCursor(query, direction) - const results = [] - - request.onerror = () => reject(new Error('Error getting data by index')) - request.onsuccess = (event) => { - const cursor = event.target.result - if (cursor && results.length < limit) { - results.push(cursor.value) - cursor.continue() - } else { - resolve(results) - } - } - }) - }) - }, [queueOperation, storeName]) - - const getPage = useCallback((page = 1, pageSize = 10, indexName = null, query = null, direction = 'next') => { - return queueOperation((db) => { - return new Promise((resolve, reject) => { - const transaction = db.transaction(storeName, 'readonly') - const store = transaction.objectStore(storeName) - const target = indexName ? store.index(indexName) : store - const request = target.openCursor(query, direction) - const results = [] - let skipped = 0 - let hasMore = false - - request.onerror = () => reject(new Error('Error getting page')) - request.onsuccess = (event) => { - const cursor = event.target.result - if (cursor) { - if (skipped < (page - 1) * pageSize) { - skipped++ - cursor.continue() - } else if (results.length < pageSize) { - results.push(cursor.value) - cursor.continue() - } else { - hasMore = true - } - } - if (hasMore || !cursor) { - const countRequest = target.count() - countRequest.onsuccess = () => { - resolve({ - data: results, - total: countRequest.result, - hasMore - }) - } - countRequest.onerror = () => reject(new Error('Error counting items')) - } - } - }) - }) - }, [queueOperation, storeName]) - - return { add, get, getAll, set, remove, clear, getByIndex, getAllByIndex, getPage, error, notSupported } + request.onsuccess = () => { + resolve(request.result) + } + }) } -export default useIndexedDB +async function _get (db, storeName, key) { + return await new Promise((resolve, reject) => { + let request + try { + request = db + .transaction(storeName) + .objectStore(storeName) + .get(key) + } catch (error) { + return reject(new IndexedDBGetError(error?.message)) + } + + request.onerror = (event) => { + reject(new IndexedDBGetError(event.target?.error?.message)) + } + + request.onsuccess = () => { + resolve(request.result) + } + }) +} + +async function _delete (dbName) { + return await new Promise((resolve, reject) => { + if (typeof window.indexedDB === 'undefined') { + return reject(new IndexedDBOpenError('IndexedDB unavailable')) + } + + const request = window.indexedDB.deleteDatabase(dbName) + + request.onerror = (event) => { + reject(new IndexedDBDeleteError(event.target?.error?.message)) + } + + request.onsuccess = () => { + resolve(request.result) + } + }) +} + +class IndexedDBError extends Error { + constructor (message) { + super(message) + this.name = 'IndexedDBError' + } +} + +class IndexedDBOpenError extends IndexedDBError { + constructor (message) { + super(message) + this.name = 'IndexedDBOpenError' + } +} + +class IndexedDBSetError extends IndexedDBError { + constructor (message) { + super(message) + this.name = 'IndexedDBSetError' + } +} + +class IndexedDBGetError extends IndexedDBError { + constructor (message) { + super(message) + this.name = 'IndexedDBGetError' + } +} + +class IndexedDBDeleteError extends IndexedDBError { + constructor (message) { + super(message) + this.name = 'IndexedDBDeleteError' + } +} diff --git a/components/use-invoice.js b/components/use-invoice.js index f59cd1db..a12d4d1f 100644 --- a/components/use-invoice.js +++ b/components/use-invoice.js @@ -1,8 +1,8 @@ import { useApolloClient, useMutation } from '@apollo/client' import { useCallback, useMemo } from 'react' -import { InvoiceCanceledError, InvoiceExpiredError, WalletReceiverError } from '@/wallets/errors' +import { InvoiceCanceledError, InvoiceExpiredError, WalletReceiverError } from '@/wallets/client/errors' import { RETRY_PAID_ACTION } from '@/fragments/paidAction' -import { INVOICE, CANCEL_INVOICE } from '@/fragments/wallet' +import { INVOICE, CANCEL_INVOICE } from '@/fragments/invoice' export default function useInvoice () { const client = useApolloClient() diff --git a/components/use-item-submit.js b/components/use-item-submit.js index cd6eb867..b6ce3882 100644 --- a/components/use-item-submit.js +++ b/components/use-item-submit.js @@ -8,7 +8,7 @@ import { RETRY_PAID_ACTION } from '@/fragments/paidAction' import gql from 'graphql-tag' import { USER_ID } from '@/lib/constants' import { useMe } from './me' -import { useWalletRecvPrompt, WalletPromptClosed } from '@/wallets/prompt' +import { useWalletRecvPrompt, WalletPromptClosed } from '@/wallets/client/hooks' // this is intented to be compatible with upsert item mutations // so that it can be reused for all post types and comments and we don't have diff --git a/components/use-paid-mutation.js b/components/use-paid-mutation.js index f093ff45..110ac17b 100644 --- a/components/use-paid-mutation.js +++ b/components/use-paid-mutation.js @@ -2,9 +2,9 @@ import { useApolloClient, useLazyQuery, useMutation } from '@apollo/client' import { useCallback, useState } from 'react' import useQrPayment from '@/components/use-qr-payment' import useInvoice from '@/components/use-invoice' -import { InvoiceCanceledError, InvoiceExpiredError, WalletError, WalletPaymentError } from '@/wallets/errors' +import { InvoiceCanceledError, InvoiceExpiredError, WalletError, WalletPaymentError } from '@/wallets/client/errors' import { GET_PAID_ACTION } from '@/fragments/paidAction' -import { useWalletPayment } from '@/wallets/payment' +import { useWalletPayment } from '@/wallets/client/hooks' /* this is just like useMutation with a few changes: diff --git a/components/use-qr-payment.js b/components/use-qr-payment.js index 8fbc9cf0..bfb29189 100644 --- a/components/use-qr-payment.js +++ b/components/use-qr-payment.js @@ -1,9 +1,9 @@ import { useCallback } from 'react' import Invoice from '@/components/invoice' -import { InvoiceCanceledError, InvoiceExpiredError, AnonWalletError } from '@/wallets/errors' +import { AnonWalletError, InvoiceCanceledError, InvoiceExpiredError } from '@/wallets/client/errors' import { useShowModal } from '@/components/modal' import useInvoice from '@/components/use-invoice' -import { sendPayment } from '@/wallets/webln/client' +import { sendPayment as weblnSendPayment } from '@/wallets/client/protocols/webln' export default function useQrPayment () { const invoice = useInvoice() @@ -19,7 +19,7 @@ export default function useQrPayment () { ) => { // if anon user and webln is available, try to pay with webln if (typeof window.webln !== 'undefined' && (walletError instanceof AnonWalletError)) { - sendPayment(inv.bolt11).catch(e => { console.error('WebLN payment failed:', e) }) + weblnSendPayment(inv.bolt11).catch(e => { console.error('WebLN payment failed:', e) }) } return await new Promise((resolve, reject) => { let paid diff --git a/components/vault/use-vault-configurator.js b/components/vault/use-vault-configurator.js deleted file mode 100644 index a3269f5a..00000000 --- a/components/vault/use-vault-configurator.js +++ /dev/null @@ -1,175 +0,0 @@ -import { useMutation, useQuery, makeVar, useReactiveVar } from '@apollo/client' -import { useMe } from '../me' -import { useToast } from '../toast' -import useIndexedDB, { getDbName } from '../use-indexeddb' -import { useCallback, useEffect, useMemo } from 'react' -import { E_VAULT_KEY_EXISTS } from '@/lib/error' -import { CLEAR_VAULT, GET_VAULT_ENTRIES, UPDATE_VAULT_KEY } from '@/fragments/vault' -import { toHex } from '@/lib/hex' -import { decryptValue, encryptValue } from './use-vault' - -const useImperativeQuery = (query) => { - const { refetch } = useQuery(query, { skip: true }) - - const imperativelyCallQuery = (variables) => { - return refetch(variables) - } - - return imperativelyCallQuery -} - -// reactive variable to store the vault key shared by all vaults -// so all vaults can react to changes in the vault key -// an alternative is to create a vault context which may be more idiomatic(?) -const keyReactiveVar = makeVar(null) - -export function useVaultConfigurator ({ onVaultKeySet, beforeDisconnectVault } = {}) { - const { me } = useMe() - const toaster = useToast() - const idbConfig = useMemo(() => ({ dbName: getDbName(me?.id, 'vault'), storeName: 'vault', options: {} }), [me?.id]) - const { set, get, remove } = useIndexedDB(idbConfig) - const [updateVaultKey] = useMutation(UPDATE_VAULT_KEY) - const getVaultEntries = useImperativeQuery(GET_VAULT_ENTRIES) - const key = useReactiveVar(keyReactiveVar) - - const disconnectVault = useCallback(async () => { - console.log('disconnecting vault') - beforeDisconnectVault?.() - await remove('key') - keyReactiveVar(null) - }, [remove, keyReactiveVar, beforeDisconnectVault]) - - useEffect(() => { - if (!me) return - - (async () => { - try { - const localVaultKey = await get('key') - if (localVaultKey?.hash && 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?.hash, '!=', me?.privates?.vaultKeyHash) - await disconnectVault() - return - } - keyReactiveVar(localVaultKey) - } catch (e) { - console.error('error loading vault configuration', e) - // toaster?.danger('error loading vault configuration ' + e.message) - } - })() - }, [me?.privates?.vaultKeyHash, get, remove, keyReactiveVar, disconnectVault]) - - // clear vault: remove everything and reset the key - const [clearVault] = useMutation(CLEAR_VAULT, { - onCompleted: async () => { - try { - await remove('key') - keyReactiveVar(null) - } catch (e) { - toaster.danger('error clearing vault ' + e.message) - } - } - }) - - // initialize the vault and set a vault key - const setVaultKey = useCallback(async (passphrase) => { - try { - const oldKeyValue = await get('key') - const vaultKey = await deriveKey(me.id, passphrase) - const { data } = await getVaultEntries() - - const encrypt = async value => { - return await encryptValue(vaultKey.key, value) - } - - const entries = [] - if (oldKeyValue?.key) { - for (const { key, iv, value } of data.getVaultEntries) { - const plainValue = await decryptValue(oldKeyValue.key, { iv, value }) - entries.push({ key, ...await encrypt(plainValue) }) - } - } - - await updateVaultKey({ - variables: { entries, hash: vaultKey.hash }, - update: (cache, { data }) => { - cache.modify({ - id: `User:${me.id}`, - fields: { - privates: (existing) => ({ - ...existing, - vaultKeyHash: 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) - } - }) - - await set('key', vaultKey) - onVaultKeySet?.(encrypt).catch(console.error) - keyReactiveVar(vaultKey) - } catch (e) { - console.error('error setting vault key', e) - toaster.danger(e.message) - } - }, [getVaultEntries, updateVaultKey, set, get, remove, onVaultKeySet, keyReactiveVar, me?.id]) - - return { key, setVaultKey, clearVault, disconnectVault } -} - -/** - * Derive a key to be used for the vault encryption - * @param {string | number} userId - the id of the user (used for salting) - * @param {string} passphrase - the passphrase to derive the key from - * @returns {Promise<{key: CryptoKey, hash: string, extractable: boolean}>} an un-extractable CryptoKey and its hash - */ -async function deriveKey (userId, passphrase) { - const enc = new TextEncoder() - - const keyMaterial = await window.crypto.subtle.importKey( - 'raw', - enc.encode(passphrase), - { name: 'PBKDF2' }, - false, - ['deriveKey'] - ) - - const key = await window.crypto.subtle.deriveKey( - { - name: 'PBKDF2', - salt: enc.encode(`stacker${userId}`), - // 600,000 iterations is recommended by OWASP - // see https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2 - iterations: 600_000, - hash: 'SHA-256' - }, - keyMaterial, - { name: 'AES-GCM', length: 256 }, - true, - ['encrypt', 'decrypt'] - ) - - const rawKey = await window.crypto.subtle.exportKey('raw', key) - const hash = toHex(await window.crypto.subtle.digest('SHA-256', rawKey)) - const unextractableKey = await window.crypto.subtle.importKey( - 'raw', - rawKey, - { name: 'AES-GCM' }, - false, - ['encrypt', 'decrypt'] - ) - - return { - key: unextractableKey, - hash - } -} diff --git a/components/vault/use-vault.js b/components/vault/use-vault.js deleted file mode 100644 index d66f8048..00000000 --- a/components/vault/use-vault.js +++ /dev/null @@ -1,64 +0,0 @@ -import { useCallback } from 'react' -import { useVaultConfigurator } from './use-vault-configurator' -import { fromHex, toHex } from '@/lib/hex' - -export default function useVault () { - const { key } = useVaultConfigurator() - - const encrypt = useCallback(async (value) => { - if (!key) throw new Error('no vault key set') - return await encryptValue(key.key, value) - }, [key]) - - const decrypt = useCallback(async ({ iv, value }) => { - if (!key) throw new Error('no vault key set') - return await decryptValue(key.key, { iv, value }) - }, [key]) - - return { encrypt, decrypt, isActive: !!key?.key } -} - -/** - * Encrypt data using AES-GCM - * @param {CryptoKey} sharedKey - the key to use for encryption - * @param {Object} value - the value to encrypt - * @returns {Promise} an object with iv and value properties, can be passed to decryptValue to get the original data back - */ -export async function encryptValue (sharedKey, value) { - // random IVs are _really_ important in GCM: reusing the IV once can lead to catastrophic failure - // see https://crypto.stackexchange.com/questions/26790/how-bad-it-is-using-the-same-iv-twice-with-aes-gcm - // 12 bytes (96 bits) is the recommended IV size for AES-GCM - const iv = window.crypto.getRandomValues(new Uint8Array(12)) - const encoded = new TextEncoder().encode(JSON.stringify(value)) - const encrypted = await window.crypto.subtle.encrypt( - { - name: 'AES-GCM', - iv - }, - sharedKey, - encoded - ) - return { - iv: toHex(iv.buffer), - value: toHex(encrypted) - } -} - -/** - * Decrypt data using AES-GCM - * @param {CryptoKey} sharedKey - the key to use for decryption - * @param {Object} encryptedValue - the encrypted value as returned by encryptValue - * @returns {Promise} the original unencrypted data - */ -export async function decryptValue (sharedKey, { iv, value }) { - const decrypted = await window.crypto.subtle.decrypt( - { - name: 'AES-GCM', - iv: fromHex(iv) - }, - sharedKey, - fromHex(value) - ) - const decoded = new TextDecoder().decode(decrypted) - return JSON.parse(decoded) -} diff --git a/docker-compose.yml b/docker-compose.yml index ee09f0cc..76692875 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,6 +36,7 @@ services: env_file: *env_file volumes: - ./docker/db/seed.sql:/docker-entrypoint-initdb.d/seed.sql + - ./docker/db/wallet-seed.sql:/docker-entrypoint-initdb.d/wallet-seed.sql - db:/var/lib/postgresql/data labels: CONNECT: "localhost:5431" diff --git a/docker/db/wallet-seed.sql b/docker/db/wallet-seed.sql new file mode 100644 index 00000000..5a242f1c --- /dev/null +++ b/docker/db/wallet-seed.sql @@ -0,0 +1,186 @@ +/* + * This seed file inserts test wallets into the database to test wallet migrations. + * Only the wallets for which we could hardcode the configuration when this file was created will work to send or receive zaps. + * For example, NWC won't work for send or receive because it generates a random public key and secret every time the container is started for the first time. + */ + +-- device sync passphrase: media fit youth secret combine live cupboard response enable loyal kitchen angle +COPY public."users" ("id", "name", "vaultKeyHash") FROM stdin; +21001 test_wallet_v2 0feb0e0ed8684eaf37a995c4decac6d360125d40ff3fffe26239bb7ffd810853 +\. + +-- triggers will update the wallet JSON column in the Wallet table when we insert rows into the other wallet tables +COPY public."Wallet" ("id", "userId", "type", "enabled") FROM stdin; +1 21001 LIGHTNING_ADDRESS true +2 21001 NWC true +3 21001 WEBLN true +4 21001 LNBITS true +5 21001 CLN true +6 21001 BLINK true +7 21001 PHOENIXD true +8 21001 LND true +9 21001 LNC true +10 21001 LIGHTNING_ADDRESS true +11 21001 LIGHTNING_ADDRESS true +12 21001 LIGHTNING_ADDRESS true +13 21001 LIGHTNING_ADDRESS true +14 21001 LIGHTNING_ADDRESS true +15 21001 LIGHTNING_ADDRESS true +16 21001 LIGHTNING_ADDRESS true +17 21001 LIGHTNING_ADDRESS true +18 21001 LIGHTNING_ADDRESS true +19 21001 LIGHTNING_ADDRESS true +20 21001 LIGHTNING_ADDRESS true +21 21001 LIGHTNING_ADDRESS true +22 21001 LIGHTNING_ADDRESS true +23 21001 LIGHTNING_ADDRESS true +24 21001 LIGHTNING_ADDRESS true +25 21001 LIGHTNING_ADDRESS true +26 21001 LIGHTNING_ADDRESS true +27 21001 LIGHTNING_ADDRESS true +28 21001 NWC true +29 21001 NWC true +\. + +COPY public."WalletLightningAddress" ("id", "walletId", "address") FROM stdin; +1 1 john_doe@getalby.com +2 10 john_doe@rizful.com +3 11 john_doe@fountain.fm +4 12 john_doe@primal.net +5 13 john_doe@coinos.io +6 14 john_doe@speed.app +7 15 john_doe@tryspeed.com +8 16 john_doe@blink.sv +9 17 john_doe@zbd.gg +10 18 john_doe@strike.me +11 19 john_doe@minibits.cash +12 20 john_doe@npub.cash +13 21 john_doe@zeuspay.com +14 22 john_doe@fountain.fm +15 23 john_doe@lifpay.me +16 24 john_doe@rizful.com +17 25 john_doe@vlt.ge +19 26 john_doe@blixtwallet.com +20 27 john_doe@shockwallet.app +\. + +COPY public."WalletNWC" ("id", "walletId", "nwcUrlRecv") FROM stdin; +1 2 nostr+walletconnect://8682ce552a852b5e21c8fe1235823a6f175641538f4c5431ec559a75dfb7f73a?relay=wss://relay.getalby.com/v1&secret=99669866becdbfacef4e9c3f0d00f085ee1174bc973135f158bab769f37152b9&lud16=john_doe@getalby.com +2 28 nostr+walletconnect://8682ce552a852b5e21c8fe1235823a6f175641538f4c5431ec559a75dfb7f73a?relay=wss://relay-nwc.rizful.com&secret=99669866becdbfacef4e9c3f0d00f085ee1174bc973135f158bab769f37152b9 +\. + +COPY public."WalletLNbits" ("id", "walletId", "url", "invoiceKey") FROM stdin; +1 4 http://localhost:5001 5deed7cd634e4306bb5e696f4a03cdac +\. + +COPY public."WalletCLN" ("id", "walletId", "socket", "rune", "cert") FROM stdin; +1 5 cln:3010 Fz6ox9zLwTRfHSaKbxdr5SK4KyxAjL_UEniED6UEGRw9MCZtZXRob2Q9aW52b2ljZQ== LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tDQpNSUlCY2pDQ0FSaWdBd0lCQWdJSkFOclN2UFovWTNLRU1Bb0dDQ3FHU000OUJBTUNNQll4RkRBU0JnTlZCQU1NDQpDMk5zYmlCU2IyOTBJRU5CTUNBWERUYzFNREV3TVRBd01EQXdNRm9ZRHpRd09UWXdNVEF4TURBd01EQXdXakFXDQpNUlF3RWdZRFZRUUREQXRqYkc0Z1VtOXZkQ0JEUVRCWk1CTUdCeXFHU000OUFnRUdDQ3FHU000OUF3RUhBMElBDQpCQmptYUh1dWxjZ3dTR09ubExBSFlRbFBTUXdHWEROSld5ZnpWclY5aFRGYUJSZFFrMVl1Y3VqVFE5QXFybkVJDQpyRmR6MS9PeisyWFhENmdBMnhPbmIrNmpUVEJMTUJrR0ExVWRFUVFTTUJDQ0EyTnNib0lKYkc5allXeG9iM04wDQpNQjBHQTFVZERnUVdCQlNFY21OLzlyelMyaFI2RzdFSWdzWCs1MU4wQ2pBUEJnTlZIUk1CQWY4RUJUQURBUUgvDQpNQW9HQ0NxR1NNNDlCQU1DQTBnQU1FVUNJSENlUHZOU3Z5aUJZYXdxS2dRcXV3OUoyV1Z5SnhuMk1JWUlxejlTDQpRTDE4QWlFQWg4QlZEejhwWDdOc2xsOHNiMGJPMFJaNDljdnFRb2NDZ1ZhYnFKdVN1aWs9DQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tDQo= +\. + +COPY public."WalletBlink" ("id", "walletId", "apiKeyRecv", "currencyRecv") FROM stdin; +1 6 blink_IpGjMEmlLZrb3dx1RS5pcVm7Z6uKthb2UMg5bfGxcIV4Yae BTC +\. + +COPY public."WalletPhoenixd" ("id", "walletId", "url", "secondaryPassword") FROM stdin; +1 7 https://phoenixd.ekzy.is abb6dc487e788fcfa2bdaf587aa3f96a5ee4a3e8d7d8068131182c5919d974cd +\. + +COPY public."WalletLND" ("id", "walletId", "socket", "macaroon", "cert") FROM stdin; +1 8 lnd:10009 0201036c6e64022f030a1089912eeaa5f434e5265170565bcce0eb1201301a170a08696e766f6963657312047265616412057772697465000006200622e95cf2fe2d9a8976cbfb824809a9a5e8af861b659e396064f6de1dc79d04 LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNoVENDQWl1Z0F3SUJBZ0lSQUp5Zkg3cEdDZEhXTVJZVGo1d1pKSkF3Q2dZSUtvWkl6ajBFQXdJd09ERWYKTUIwR0ExVUVDaE1XYkc1a0lHRjFkRzluWlc1bGNtRjBaV1FnWTJWeWRERVZNQk1HQTFVRUF4TU1NR1V5T0dVNApPREkzTmpZd01CNFhEVEkxTURZd05URTRNak15TmxvWERUSTJNRGN6TVRFNE1qTXlObG93T0RFZk1CMEdBMVVFCkNoTVdiRzVrSUdGMWRHOW5aVzVsY21GMFpXUWdZMlZ5ZERFVk1CTUdBMVVFQXhNTU1HVXlPR1U0T0RJM05qWXcKTUZrd0V3WUhLb1pJemowQ0FRWUlLb1pJemowREFRY0RRZ0FFUE1lb2RYYTF2eXVxYXFaNklXbXgrNDVFdjBkUgpmQkY5SXZtMU5xQVNHUGlGT1JucEtxZVBVbm0xWmZlTUNETytwcGhQMHpGYVh4ZVBUU3BwaWMrYXlLT0NBUlF3CmdnRVFNQTRHQTFVZER3RUIvd1FFQXdJQ3BEQVRCZ05WSFNVRUREQUtCZ2dyQmdFRkJRY0RBVEFQQmdOVkhSTUIKQWY4RUJUQURBUUgvTUIwR0ExVWREZ1FXQkJUYkdKMlZDejN5WkFUd1JlUG1kckdvMnhkVmFqQ0J1QVlEVlIwUgpCSUd3TUlHdGdnd3daVEk0WlRnNE1qYzJOakNDQ1d4dlkyRnNhRzl6ZElJRGJHNWtnaFJvYjNOMExtUnZZMnRsCmNpNXBiblJsY201aGJJSStOelV5ZUdWNWIyeG1jSEJqTW5SbloybDZaSEZoYW1Kb2VXZHNjRzV4ZW10bGFtVmoKWW1oeGJIQnpNMjU0ZW5aMGMyZzNkMkZ0Y1dRdWIyNXBiMjZDQkhWdWFYaUNDblZ1YVhod1lXTnJaWFNDQjJKMQpabU52Ym02SEJIOEFBQUdIRUFBQUFBQUFBQUFBQUFBQUFBQUFBQUdIQkt3U0FBY3dDZ1lJS29aSXpqMEVBd0lEClNBQXdSUUlnY2pZZ2o5YVhpQjlOOVBmQUp0cWZRbStoYVdpbmZ0RTVXdkJ3Vis4NzgzTUNJUUNyaEx2Qys3RzQKN3NneENyYnlmLy9WdmxJN3BkakRlVFM0WGc4eHB2UmVEQT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K +\. + +COPY public."VaultEntry" ("id", "key", "iv", "value", "userId", "walletId") FROM stdin; +1 nwcUrl 926622f7139d4b4506827549 b592906ea9c3ced5df077ca1ea0c787c2ea9173e39d062466b50723d8c0a568510ed42549215c6f6e15632601248f148fa18b87d7e0fe9ad667d10a12beb79e36d3cfcaa58ca65c78e8f41ee715c2b19ac8c638353cdc9098a784104eb9b1592b233d4327556de47d218f97991392105ff0868beada2667b308d544bf9e7199f056ecb8cd9c2f87a7f8f1eda7db7e80c880de12df4ce2dc5dcc16ec836d9a9f428f2c4e36f01bdbbd85084ecac308eefa1dbdfe89c2a321a3fafa1c35265a788a352c329f9e01d0988e47f05b8575fbcfb5814 21001 2 +2 adminKey 1b911294853df2e94e4d9823 438ff80df2e58e3f7988ba828fab1e7def3934b908a58de9f5f16bb36e1ecc65e1cab43c0ec658f65bdccb0a241bb5614697 21001 4 +3 apiKey b1f3500130b16bf4997fc370 3276869cf3d8c6d844e771688f8cd1a771279867165e4b1030b7ad90d537d5cfa0a6a82c2aaefe350db2a445b3b0c3b23a068edede3e78fe5957c1cfc6b5f1fd811786793c65aad90fe8ce 21001 6 +4 currency 8174fe225f0d53957a4daced b912faacc32725b9ec01128911eb3922822fb21bdc 21001 6 +5 primaryPassword 5e709c93ca34a135dad293d7 7509592ff463f886b7f7a621928a8ac0ca56b904d5835bf77ca5914a248fb70ad231f04aed893c0ef1dd7edd2d928d482d0eaeba7ae2381f3fd70ba25cb265de6091a11231a9cd3047f22ff2f838db046e67 21001 7 +6 pairingPhrase 0196718758dea2bff7c89741 bd6ff716ec5b20dc74f6507b87ed0923c8b27e33204ae44cee47d8c7f78dd5976cc446f2c9dd918f2916611a71e20e87fb9245cacfdb35bbc527a42c0df765e2f9589e56b5b253c0d39f8e954b 21001 9 +7 localKey 227bc46af405a40cb6697344 f4589aaca476b4905980b9dd834880926aff9e9c9217afa9b33152a74255698c9284015309ae19e10481843069a052dbe1a592e14db6aa13fce4e17fd9f5f2964720ba4686a4a45a1c72681248809e8de612 21001 9 +8 remoteKey e63b62d8af6a1227129e8c7b ce97d971cdcd58b34ec2e998c6ce6df72b8c21a9cd07e69db96c9491b3d9a051cf557d721552c5cc565a4d7f1bf1ad70b20048b90e1b244e77f0b635b5dbd798e0538f85d7008b29918a7e589dc1c2bde465c50c 21001 9 +9 serverHost a537d212e719810f6cbbc696 449c550ca2c24e761802087e5bc5637d0b4b231d9b771fbefbee6ed7c0a728862adc677cc283a373ec25f01003009f0c9cd18f884d08 21001 9 +\. + +COPY public."Invoice" ("id", "userId", "hash", "bolt11", "expiresAt", "msatsRequested") FROM stdin; +1 21001 d1bc8d3ba4afc7e109612cb73acbdddac052c93025aa1f82942edabb7deb82a1 lnbc 2025-05-16 00:00:00 1000000 +2 21001 d1bc8d3ba4afc7e109612cb73acbdddac052c93025aa1f82942edabb7deb82a2 lnbc 2025-05-16 00:00:00 2000000 +3 21001 d1bc8d3ba4afc7e109612cb73acbdddac052c93025aa1f82942edabb7deb82a3 lnbc 2025-05-16 00:00:00 3000000 +4 21001 d1bc8d3ba4afc7e109612cb73acbdddac052c93025aa1f82942edabb7deb82a4 lnbc 2025-05-16 00:00:00 4000000 +5 21001 d1bc8d3ba4afc7e109612cb73acbdddac052c93025aa1f82942edabb7deb82a5 lnbc 2025-05-16 00:00:00 1000000 +6 21001 d1bc8d3ba4afc7e109612cb73acbdddac052c93025aa1f82942edabb7deb82a6 lnbc 2025-05-16 00:00:00 2000000 +7 21001 d1bc8d3ba4afc7e109612cb73acbdddac052c93025aa1f82942edabb7deb82a7 lnbc 2025-05-16 00:00:00 3000000 +8 21001 d1bc8d3ba4afc7e109612cb73acbdddac052c93025aa1f82942edabb7deb82a8 lnbc 2025-05-16 00:00:00 4000000 +9 21001 d1bc8d3ba4afc7e109612cb73acbdddac052c93025aa1f82942edabb7deb82a9 lnbc 2025-05-16 00:00:00 4000000 +10 21001 d1bc8d3ba4afc7e109612cb73acbdddac052c93025aa1f82942edabb7deb82aa lnbc 2025-05-16 00:00:00 4000000 +11 21001 d1bc8d3ba4afc7e109612cb73acbdddac052c93025aa1f82942edabb7deb82ab lnbc 2025-05-16 00:00:00 4000000 +12 21001 d1bc8d3ba4afc7e109612cb73acbdddac052c93025aa1f82942edabb7deb82ac lnbc 2025-05-16 00:00:00 4000000 +13 21001 d1bc8d3ba4afc7e109612cb73acbdddac052c93025aa1f82942edabb7deb82ad lnbc 2025-05-16 00:00:00 4000000 +14 21001 d1bc8d3ba4afc7e109612cb73acbdddac052c93025aa1f82942edabb7deb82ae lnbc 2025-05-16 00:00:00 4000000 +15 21001 d1bc8d3ba4afc7e109612cb73acbdddac052c93025aa1f82942edabb7deb82af lnbc 2025-05-16 00:00:00 4000000 +16 21001 d1bc8d3ba4afc7e109612cb73acbdddac052c93025aa1f82942edabb7deb8210 lnbc 2025-05-16 00:00:00 4000000 +\. + +COPY public."Withdrawl" ("id", "userId", "walletId", "msatsPaying", "msatsFeePaying") FROM stdin; +1 21001 1 1000 0 +2 21001 2 1000 0 +3 21001 2 1000 0 +4 21001 5 1000 0 +5 21001 5 1000 0 +6 21001 6 1000 0 +7 21001 7 1000 0 +8 21001 7 1000 0 +9 21001 8 1000 0 +10 21001 10 1000 0 +11 21001 11 1000 0 +12 21001 27 1000 0 +13 21001 28 1000 0 +14 21001 29 1000 0 +15 21001 1 1000 0 +16 21001 4 1000 0 +17 21001 4 1000 0 +18 21001 7 1000 0 +19 21001 7 1000 0 +20 21001 8 1000 0 +\. + +COPY public."InvoiceForward" ("id", "walletId", "bolt11", "maxFeeMsats", "invoiceId", "withdrawlId") FROM stdin; +1 1 lnbc 1000 1 1 +2 2 lnbc 1000 2 2 +3 4 lnbc 1000 3 3 +4 4 lnbc 1000 4 4 +5 5 lnbc 1000 5 5 +6 6 lnbc 1000 6 6 +7 7 lnbc 1000 7 7 +8 8 lnbc 1000 8 8 +9 27 lnbc 1000 9 9 +10 28 lnbc 1000 10 10 +11 29 lnbc 1000 11 11 +12 4 lnbc 1000 12 12 +13 4 lnbc 1000 13 13 +14 5 lnbc 1000 14 14 +15 6 lnbc 1000 15 15 +16 7 lnbc 1000 16 16 +\. + +SELECT pg_catalog.setval('public."InvoiceForward_id_seq"', 16, true); + +COPY public."DirectPayment" ("id", "walletId", "senderId", "receiverId", "msats") FROM stdin; +1 1 21001 21001 1000 +2 2 21001 21001 1000 +3 4 21001 21001 1000 +4 5 21001 21001 1000 +5 6 21001 21001 1000 +6 7 21001 21001 1000 +7 8 21001 21001 1000 +8 16 21001 21001 1000 +9 27 21001 21001 1000 +10 28 21001 21001 1000 +11 29 21001 21001 1000 +12 7 21001 21001 1000 +13 7 21001 21001 1000 +14 5 21001 21001 1000 +15 5 21001 21001 1000 +16 4 21001 21001 1000 +\. + +SELECT pg_catalog.setval('public."DirectPayment_id_seq"', 16, true); diff --git a/fragments/wallet.js b/fragments/invoice.js similarity index 62% rename from fragments/wallet.js rename to fragments/invoice.js index ce1090a5..60bb43f4 100644 --- a/fragments/wallet.js +++ b/fragments/invoice.js @@ -1,6 +1,5 @@ import { gql } from '@apollo/client' import { ITEM_FULL_FIELDS } from './items' -import { VAULT_ENTRY_FIELDS } from './vault' export const INVOICE_FIELDS = gql` fragment InvoiceFields on Invoice { @@ -121,89 +120,6 @@ export const SEND_TO_LNADDR = gql` } }` -export const REMOVE_WALLET = -gql` -mutation removeWallet($id: ID!) { - removeWallet(id: $id) -} -` -// XXX [WALLET] this needs to be updated if another server wallet is added -export const WALLET_FIELDS = gql` - ${VAULT_ENTRY_FIELDS} - fragment WalletFields on Wallet { - id - priority - type - updatedAt - enabled - vaultEntries { - ...VaultEntryFields - } - wallet { - __typename - ... on WalletLightningAddress { - address - } - ... on WalletLnd { - socket - macaroon - cert - } - ... on WalletCln { - socket - rune - cert - } - ... on WalletLnbits { - url - invoiceKey - } - ... on WalletNwc { - nwcUrlRecv - } - ... on WalletPhoenixd { - url - secondaryPassword - } - ... on WalletBlink { - apiKeyRecv - currencyRecv - } - } - } -` - -export const WALLETS = gql` - ${WALLET_FIELDS} - query Wallets { - wallets { - ...WalletFields - } - } -` - -export const WALLET_LOGS = gql` - query WalletLogs($type: String, $from: String, $to: String, $cursor: String) { - walletLogs(type: $type, from: $from, to: $to, cursor: $cursor) { - cursor - entries { - id - createdAt - wallet - level - message - context - } - } - } -` - -export const SET_WALLET_PRIORITY = gql` - mutation SetWalletPriority($id: ID!, $priority: Int!) { - setWalletPriority(id: $id, priority: $priority) - } -` - export const CANCEL_INVOICE = gql` ${INVOICE_FIELDS} mutation cancelInvoice($hash: String!, $hmac: String, $userCancel: Boolean) { diff --git a/fragments/notifications.js b/fragments/notifications.js index 48a5c046..3460b00d 100644 --- a/fragments/notifications.js +++ b/fragments/notifications.js @@ -2,7 +2,7 @@ import { gql } from '@apollo/client' import { ITEM_FULL_FIELDS, POLL_FIELDS } from './items' import { INVITE_FIELDS } from './invites' import { SUB_FIELDS } from './subs' -import { INVOICE_FIELDS } from './wallet' +import { INVOICE_FIELDS } from './invoice' export const HAS_NOTIFICATIONS = gql`{ hasNewNotes }` diff --git a/fragments/paidAction.js b/fragments/paidAction.js index 60ed16e2..f5ccdea3 100644 --- a/fragments/paidAction.js +++ b/fragments/paidAction.js @@ -1,7 +1,7 @@ import gql from 'graphql-tag' import { COMMENTS } from './comments' import { SUB_FULL_FIELDS } from './subs' -import { INVOICE_FIELDS } from './wallet' +import { INVOICE_FIELDS } from './invoice' const HASH_HMAC_INPUT_1 = '$hash: String, $hmac: String' const HASH_HMAC_INPUT_2 = 'hash: $hash, hmac: $hmac' diff --git a/fragments/users.js b/fragments/users.js index 86b594ba..66107639 100644 --- a/fragments/users.js +++ b/fragments/users.js @@ -25,9 +25,6 @@ ${STREAK_FIELDS} autoDropBolt11s noReferralLinks fiatCurrency - autoWithdrawMaxFeePercent - autoWithdrawMaxFeeTotal - autoWithdrawThreshold withdrawMaxFeeDefault satsFilter hideFromTopUsers @@ -52,7 +49,7 @@ ${STREAK_FIELDS} disableFreebies vaultKeyHash walletsUpdatedAt - proxyReceive + showPassphrase } optional { isContributor @@ -113,9 +110,6 @@ export const SETTINGS_FIELDS = gql` apiKey } apiKeyEnabled - proxyReceive - receiveCreditsBelowSats - sendCreditsBelowSats } }` diff --git a/fragments/vault.js b/fragments/vault.js deleted file mode 100644 index 10c3f31c..00000000 --- a/fragments/vault.js +++ /dev/null @@ -1,33 +0,0 @@ -import { gql } from '@apollo/client' - -export const VAULT_ENTRY_FIELDS = gql` - fragment VaultEntryFields on VaultEntry { - id - key - iv - value - createdAt - updatedAt - } -` - -export const GET_VAULT_ENTRIES = gql` - ${VAULT_ENTRY_FIELDS} - query GetVaultEntries { - getVaultEntries { - ...VaultEntryFields - } - } -` - -export const CLEAR_VAULT = gql` - mutation ClearVault { - clearVault - } -` - -export const UPDATE_VAULT_KEY = gql` - mutation updateVaultKey($entries: [VaultEntryInput!]!, $hash: String!) { - updateVaultKey(entries: $entries, hash: $hash) - } -` diff --git a/lib/apollo.js b/lib/apollo.js index 3739ba3f..4a60c2a5 100644 --- a/lib/apollo.js +++ b/lib/apollo.js @@ -95,6 +95,10 @@ function getClient (uri) { 'Reminder', 'ItemMention', 'Invoicification' + ], + WalletOrTemplate: [ + 'Wallet', + 'WalletTemplate' ] }, typePolicies: { @@ -290,6 +294,12 @@ function getClient (uri) { } } }, + walletLogs: { + keyArgs: ['protocolId'], + merge (existing, incoming) { + return incoming + } + }, failedInvoices: { keyArgs: [], merge (existing, incoming) { diff --git a/lib/url.js b/lib/url.js index c6871fc8..4c5baa17 100644 --- a/lib/url.js +++ b/lib/url.js @@ -187,32 +187,6 @@ export function stripTrailingSlash (uri) { return uri.endsWith('/') ? uri.slice(0, -1) : uri } -export function parseNwcUrl (walletConnectUrl) { - if (!walletConnectUrl) return {} - - walletConnectUrl = walletConnectUrl - .replace('nostrwalletconnect://', 'http://') - .replace('nostr+walletconnect://', 'http://') // makes it possible to parse with URL in the different environments (browser/node/...) - - // XXX There is a bug in parsing since we use the URL constructor for parsing: - // A wallet pubkey matching /^[0-9a-fA-F]{64}$/ might not be a valid hostname. - // Example: 11111111111 (10 1's) is a valid hostname (gets parsed as IPv4) but 111111111111 (11 1's) is not. - // See https://stackoverflow.com/questions/56804936/how-does-only-numbers-in-url-resolve-to-a-domain - // However, this seems to only get triggered if a wallet pubkey only contains digits so this is pretty improbable. - const url = new URL(walletConnectUrl) - const params = {} - params.walletPubkey = url.host - const secret = url.searchParams.get('secret') - const relayUrls = url.searchParams.getAll('relay') - if (secret) { - params.secret = secret - } - if (relayUrls) { - params.relayUrls = relayUrls - } - return params -} - export class ResponseAssertError extends Error { constructor (res, { message, method } = {}) { const urlPart = method ? `${method} ${res.url}` : res.url diff --git a/lib/validate.js b/lib/validate.js index e103c4b8..71e08300 100644 --- a/lib/validate.js +++ b/lib/validate.js @@ -12,7 +12,6 @@ import { numWithUnits } from './format' import { SUB } from '@/fragments/subs' import { NAME_QUERY } from '@/fragments/users' import { datePivot } from './time' -import bip39Words from './bip39-words' export async function validateSchema (schema, data, args) { try { @@ -52,12 +51,6 @@ export const lightningAddressValidator = process.env.NODE_ENV === 'development' 'address is no good') : string().email('address is no good') -export const externalLightningAddressValidator = lightningAddressValidator.test({ - name: 'address', - test: addr => !addr.toLowerCase().endsWith('@stacker.news'), - message: 'lightning address must be external' -}) - async function usernameExists (name, { client, models }) { if (!client && !models) { throw new Error('cannot check for user') @@ -480,6 +473,15 @@ export const settingsSchema = object().shape({ // exclude from cyclic analysis. see https://github.com/jquense/yup/issues/720 }, [['tipRandomMax', 'tipRandomMin']]) +export const walletSettingsSchema = object({ + autoWithdrawThreshold: intValidator.min(0, 'must be greater or equal to 0').required('required'), + autoWithdrawMaxFeePercent: floatValidator.min(0, 'must be greater or equal to 0').required('required'), + autoWithdrawMaxFeeTotal: intValidator.min(0, 'must be greater or equal to 0').required('required'), + receiveCreditsBelowSats: intValidator.min(0, 'must be greater or equal to 0').required('required'), + sendCreditsBelowSats: intValidator.min(0, 'must be greater or equal to 0').required('required'), + proxyReceive: boolean().required('required') +}) + const warningMessage = 'If I logout, even accidentally, I will never be able to access my account again' export const lastAuthRemovalSchema = object({ warning: string().matches(warningMessage, 'does not match').required('required') @@ -513,23 +515,3 @@ export const lud18PayerDataSchema = (k1) => object({ email: string().email('bad email address'), identifier: string() }) - -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.slice(0, 10)}${w.length > 10 ? '...' : ''}' is not a valid pairing phrase word` }) - } - } - - if (words.length < 12) { - return context.createError({ message: 'needs at least 12 words' }) - } - - return true - }) -}) diff --git a/lib/yup.js b/lib/yup.js index 745cb5df..449d5aca 100644 --- a/lib/yup.js +++ b/lib/yup.js @@ -1,6 +1,4 @@ import { addMethod, string, mixed, array } from 'yup' -import { parseNwcUrl } from './url' -import { NOSTR_PUBKEY_HEX } from './nostr' import { ensureB64, HEX_REGEX } from './format' export * from 'yup' @@ -26,7 +24,7 @@ addMethod(string, 'or', orFunc) addMethod(string, 'hexOrBase64', function (schemas, msg = 'invalid hex or base64 encoding') { return this.test({ name: 'hex-or-base64', - message: 'invalid encoding', + message: msg, test: (val) => { if (typeof val === 'undefined') return true try { @@ -85,23 +83,6 @@ addMethod(string, 'ws', function (schemas, msg = 'invalid websocket') { }) }) -addMethod(string, 'socket', function (schemas, msg = 'invalid socket') { - return this.test({ - name: 'socket', - message: msg, - test: value => { - try { - const url = new URL(`http://${value}`) - return url.hostname && url.port && !url.username && !url.password && - (!url.pathname || url.pathname === '/') && !url.search && !url.hash - } catch (e) { - return false - } - }, - exclusive: false - }) -}) - addMethod(string, 'https', function () { return this.test({ name: 'https', @@ -138,33 +119,6 @@ addMethod(string, 'hex', function (msg) { }) }) -addMethod(string, 'nwcUrl', function () { - return this.test({ - test: (nwcUrl, context) => { - if (!nwcUrl) return true - - // run validation in sequence to control order of errors - // inspired by https://github.com/jquense/yup/issues/851#issuecomment-1049705180 - try { - string().matches(/^nostr\+?walletconnect:\/\//, { message: 'must start with nostr+walletconnect://' }).validateSync(nwcUrl) - let relayUrls, walletPubkey, secret - try { - ({ relayUrls, walletPubkey, secret } = parseNwcUrl(nwcUrl)) - } catch { - // invalid URL error. handle as if pubkey validation failed to not confuse user. - throw new Error('pubkey must be 64 hex chars') - } - string().required('pubkey required').trim().matches(NOSTR_PUBKEY_HEX, 'pubkey must be 64 hex chars').validateSync(walletPubkey) - array().of(string().required('relay url required').trim().wss('relay must use wss://')).min(1, 'at least one relay required').validateSync(relayUrls) - string().required('secret required').trim().matches(/^[0-9a-fA-F]{64}$/, 'secret must be 64 hex chars').validateSync(secret) - } catch (err) { - return context.createError({ message: err.message }) - } - return true - } - }) -}) - addMethod(array, 'equalto', function equals ( { required, optional }, message diff --git a/pages/_app.js b/pages/_app.js index 9c8ae2f1..f57e6729 100644 --- a/pages/_app.js +++ b/pages/_app.js @@ -19,8 +19,7 @@ import 'nprogress/nprogress.css' 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/client' -import { WalletsProvider } from '@/wallets/index' +import WalletsProvider from '@/wallets/client/context' const PWAPrompt = dynamic(() => import('react-ios-pwa-prompt'), { ssr: false }) @@ -122,26 +121,24 @@ export default function MyApp ({ Component, pageProps: { ...props } }) { - - - - - - - - - - - {!router?.query?.disablePrompt && } - - - - - - - - - + + + + + + + + + + {!router?.query?.disablePrompt && } + + + + + + + + diff --git a/pages/api/lnurlp/[username]/pay.js b/pages/api/lnurlp/[username]/pay.js index 97b30c92..6be8f860 100644 --- a/pages/api/lnurlp/[username]/pay.js +++ b/pages/api/lnurlp/[username]/pay.js @@ -8,7 +8,7 @@ import { formatMsats, toPositiveBigInt } from '@/lib/format' import assertGofacYourself from '@/api/resolvers/ofac' import performPaidAction from '@/api/paidAction' import { validateSchema, lud18PayerDataSchema } from '@/lib/validate' -import { walletLogger } from '@/api/resolvers/wallet' +import { walletLogger } from '@/wallets/server' export default async ({ query: { username, amount, nostr, comment, payerdata: payerData }, headers }, res) => { const user = await models.user.findUnique({ where: { name: username } }) @@ -16,7 +16,11 @@ export default async ({ query: { username, amount, nostr, comment, payerdata: pa return res.status(400).json({ status: 'ERROR', reason: `user @${username} does not exist` }) } - const logger = walletLogger({ models, me: user }) + if (!amount || amount < 1000) { + return res.status(400).json({ status: 'ERROR', reason: 'amount must be >=1000 msats' }) + } + + const logger = walletLogger({ models, userId: user.id }) logger.info(`${user.name}@stacker.news payment attempt`, { amount: formatMsats(amount), nostr, comment }) try { @@ -46,10 +50,6 @@ export default async ({ query: { username, amount, nostr, comment, payerdata: pa descriptionHash = lnurlPayDescriptionHashForUser(username) } - if (!amount || amount < 1000) { - return res.status(400).json({ status: 'ERROR', reason: 'amount must be >=1000 msats' }) - } - if (comment?.length > LNURLP_COMMENT_MAX_LENGTH) { return res.status(400).json({ status: 'ERROR', diff --git a/pages/directs/[id].js b/pages/directs/[id].js index c419388b..b7adcd1a 100644 --- a/pages/directs/[id].js +++ b/pages/directs/[id].js @@ -1,7 +1,7 @@ import { useQuery } from '@apollo/client' import { CenterLayout } from '@/components/layout' import { useRouter } from 'next/router' -import { DIRECT } from '@/fragments/wallet' +import { DIRECT } from '@/fragments/invoice' import { SSR, FAST_POLL_INTERVAL } from '@/lib/constants' import Bolt11Info from '@/components/bolt11-info' import { getGetServerSideProps } from '@/api/ssrApollo' diff --git a/pages/invoices/[id].js b/pages/invoices/[id].js index d18af628..8d555ae2 100644 --- a/pages/invoices/[id].js +++ b/pages/invoices/[id].js @@ -1,7 +1,7 @@ import Invoice from '@/components/invoice' import { CenterLayout } from '@/components/layout' import { useRouter } from 'next/router' -import { INVOICE_FULL } from '@/fragments/wallet' +import { INVOICE_FULL } from '@/fragments/invoice' import { getGetServerSideProps } from '@/api/ssrApollo' // force SSR to include CSP nonces diff --git a/pages/satistics/index.js b/pages/satistics/index.js index ef233185..d68eaad2 100644 --- a/pages/satistics/index.js +++ b/pages/satistics/index.js @@ -4,7 +4,7 @@ import { getGetServerSideProps } from '@/api/ssrApollo' import Nav from 'react-bootstrap/Nav' import Layout from '@/components/layout' import MoreFooter from '@/components/more-footer' -import { WALLET_HISTORY } from '@/fragments/wallet' +import { WALLET_HISTORY } from '@/fragments/invoice' import styles from '@/styles/satistics.module.css' import Moon from '@/svgs/moon-fill.svg' import Check from '@/svgs/check-double-line.svg' diff --git a/pages/settings/index.js b/pages/settings/index.js index 93cbc599..c42e12b4 100644 --- a/pages/settings/index.js +++ b/pages/settings/index.js @@ -76,11 +76,6 @@ export function SettingsHeader () { muted stackers - - - device sync - - ) @@ -155,16 +150,12 @@ export default function Settings ({ ssrData }) { hideBookmarks: settings?.hideBookmarks, hideWalletBalance: settings?.hideWalletBalance, hideIsContributor: settings?.hideIsContributor, - noReferralLinks: settings?.noReferralLinks, - proxyReceive: settings?.proxyReceive, - receiveCreditsBelowSats: settings?.receiveCreditsBelowSats, - sendCreditsBelowSats: settings?.sendCreditsBelowSats + noReferralLinks: settings?.noReferralLinks }} schema={settingsSchema} onSubmit={async ({ tipDefault, tipRandom, tipRandomMin, tipRandomMax, withdrawMaxFeeDefault, zapUndos, zapUndosEnabled, nostrPubkey, nostrRelays, satsFilter, - receiveCreditsBelowSats, sendCreditsBelowSats, ...values }) => { if (nostrPubkey.length === 0) { @@ -190,8 +181,6 @@ export default function Settings ({ ssrData }) { withdrawMaxFeeDefault: Number(withdrawMaxFeeDefault), satsFilter: Number(satsFilter), zapUndos: zapUndosEnabled ? Number(zapUndos) : null, - receiveCreditsBelowSats: Number(receiveCreditsBelowSats), - sendCreditsBelowSats: Number(sendCreditsBelowSats), nostrPubkey, nostrRelays: nostrRelaysFiltered, ...values @@ -336,35 +325,6 @@ export default function Settings ({ ssrData }) { name='noteCowboyHat' />
wallet
- sats} - /> - sats} - /> - enhance privacy of my lightning address - -
    -
  • Enabling this setting hides details (ie node pubkey) of your attached wallets when anyone pays your SN lightning address or lnurl-pay
  • -
  • The lightning invoice will appear to have SN's node as the destination to preserve your wallet's privacy
  • -
  • This will incur in a 10% fee
  • -
  • Disable this setting to receive payments directly to your attached wallets (which will reveal their details to the payer)
  • -
  • Note: this privacy behavior is standard for internal zaps/payments on SN, and this setting only applies to external payments
  • -
-
- - } - name='proxyReceive' - groupClassName='mb-0' - /> hide invoice descriptions diff --git a/pages/settings/passphrase/index.js b/pages/settings/passphrase/index.js deleted file mode 100644 index 4d17afa0..00000000 --- a/pages/settings/passphrase/index.js +++ /dev/null @@ -1,211 +0,0 @@ -import { getGetServerSideProps } from '@/api/ssrApollo' -import Layout from '@/components/layout' -import { SettingsHeader } from '../index' -import { useVaultConfigurator } from '@/components/vault/use-vault-configurator' -import { useMe } from '@/components/me' -import { Button, InputGroup } from 'react-bootstrap' -import bip39Words from '@/lib/bip39-words' -import { Form, PasswordInput, SubmitButton } from '@/components/form' -import { deviceSyncSchema } from '@/lib/validate' -import RefreshIcon from '@/svgs/refresh-line.svg' -import { useCallback, useEffect, useState } from 'react' -import { useToast } from '@/components/toast' -import { useWallets } from '@/wallets/index' - -export const getServerSideProps = getGetServerSideProps({ authRequired: true }) - -export default function DeviceSync ({ ssrData }) { - const { me } = useMe() - const { onVaultKeySet, beforeDisconnectVault } = useWallets() - const { key, setVaultKey, clearVault, disconnectVault } = - useVaultConfigurator({ onVaultKeySet, beforeDisconnectVault }) - const [passphrase, setPassphrase] = useState() - - const setSeedPassphrase = useCallback(async (passphrase) => { - await setVaultKey(passphrase) - setPassphrase(passphrase) - }, [setVaultKey]) - - const enabled = !!me?.privates?.vaultKeyHash - const connected = !!key - - return ( - -
- - -

- Device sync uses end-to-end encryption to securely synchronize your data across devices. - - Your sensitive data remains private and inaccessible to our servers while being synced across all your connected devices using only a passphrase. -

-
-
- { - (connected && passphrase && ) || - (connected && ) || - (enabled && ) || - - } -
-
-
- ) -} - -function Connect ({ passphrase }) { - return ( -
-

Connect other devices

-

- On your other devices, navigate to device sync settings and enter this exact passphrase. -

-

- Once you leave this page, this passphrase cannot be shown again. Connect all the devices you plan to use or write this passphrase down somewhere safe. -

- -
- ) -} - -function Connected ({ disconnectVault }) { - return ( -
-

Device sync is enabled!

-

- Sensitive data on this device is now securely synced between all connected devices. -

-

- Disconnect to prevent this device from syncing data or to reset your passphrase. -

-
-
- -
-
-
- ) -} - -function Enabled ({ setVaultKey, clearVault }) { - const toaster = useToast() - return ( -
-

Device sync is enabled

-

- This device is not connected. Enter or scan your passphrase to connect. If you've lost your passphrase you may reset it. -

-
{ - try { - await setVaultKey(passphrase) - } catch (e) { - console.error(e) - toaster.danger('error setting vault key') - } - }} - > - -
-
- - enable -
-
- -
- ) -} - -const generatePassphrase = (n = 12) => { - const rand = new Uint32Array(n) - window.crypto.getRandomValues(rand) - return Array.from(rand).map(i => bip39Words[i % bip39Words.length]).join(' ') -} - -function Setup ({ setSeedPassphrase }) { - const [passphrase, setPassphrase] = useState() - const toaster = useToast() - const newPassphrase = useCallback(() => { - setPassphrase(() => generatePassphrase(12)) - }, []) - - useEffect(() => { - setPassphrase(() => generatePassphrase(12)) - }, []) - - return ( -
-

Enable device sync

-

- Enable secure sync of sensitive data (like wallet credentials) between your devices. -

-

- After enabled, your passphrase can be used to connect other devices. -

-
{ - try { - await setSeedPassphrase(passphrase) - } catch (e) { - console.error(e) - toaster.danger('error setting passphrase') - } - }} - > - - - - } - /> -
-
-
- enable -
-
-
- -
- ) -} diff --git a/pages/wallets/[...slug].js b/pages/wallets/[...slug].js new file mode 100644 index 00000000..31059d72 --- /dev/null +++ b/pages/wallets/[...slug].js @@ -0,0 +1,20 @@ +import { getGetServerSideProps } from '@/api/ssrApollo' +import { WalletForms as WalletFormsComponent } from '@/wallets/client/components' +import { unurlify } from '@/wallets/lib/util' +import { useParams } from 'next/navigation' + +export const getServerSideProps = getGetServerSideProps({ authRequired: true }) + +export default function WalletForms () { + const params = useParams() + const walletName = unurlify(params.slug[0]) + + // if the wallet name is a number, we are showing a configured wallet + // otherwise, we are showing a template + const isNumber = !Number.isNaN(Number(walletName)) + if (isNumber) { + return + } + + return +} diff --git a/pages/wallets/[wallet].js b/pages/wallets/[wallet].js deleted file mode 100644 index 5b8f7ae5..00000000 --- a/pages/wallets/[wallet].js +++ /dev/null @@ -1,187 +0,0 @@ -import { getGetServerSideProps } from '@/api/ssrApollo' -import { Form, ClientInput, PasswordInput, CheckboxGroup, Checkbox } from '@/components/form' -import { CenterLayout } from '@/components/layout' -import { WalletSecurityBanner } from '@/components/banners' -import { WalletLogs } from '@/wallets/logger' -import { useToast } from '@/components/toast' -import { useRouter } from 'next/router' -import { useWallet } from '@/wallets/index' -import Info from '@/components/info' -import Text from '@/components/text' -import { autowithdrawInitial, AutowithdrawSettings } from '@/components/autowithdraw-shared' -import { canReceive, canSend, isConfigured } from '@/wallets/common' -import { SSR } from '@/lib/constants' -import WalletButtonBar from '@/wallets/buttonbar' -import { useWalletConfigurator } from '@/wallets/config' -import { useCallback, useMemo } from 'react' -import { useMe } from '@/components/me' -import validateWallet from '@/wallets/validate' -import { ValidationError } from 'yup' -import { useFormikContext } from 'formik' -import { useWalletImage } from '@/wallets/image' -import styles from '@/styles/wallet.module.css' - -export const getServerSideProps = getGetServerSideProps({ authRequired: true }) - -export default function WalletSettings () { - const toaster = useToast() - const router = useRouter() - const { wallet: name } = router.query - const wallet = useWallet(name) - const { me } = useMe() - const { save, detach } = useWalletConfigurator(wallet) - const image = useWalletImage(wallet) - - const initial = useMemo(() => { - const initial = wallet?.def.fields.reduce((acc, field) => { - // We still need to run over all wallet fields via reduce - // even though we use wallet.config as the initial value - // since wallet.config is empty when wallet is not configured. - // Also, wallet.config includes general fields like - // 'enabled' and 'priority' which are not defined in wallet.fields. - return { - ...acc, - [field.name]: wallet?.config?.[field.name] || field.defaultValue || '' - } - }, wallet?.config) - - if (wallet?.def.fields.every(f => f.clientOnly)) { - return initial - } - - return { - ...initial, - ...autowithdrawInitial({ me }) - } - }, [wallet, me]) - - const validate = useCallback(async (data) => { - try { - await validateWallet(wallet.def, data, - { yupOptions: { abortEarly: false }, topLevel: false, skipGenerated: true }) - } catch (error) { - if (error instanceof ValidationError) { - return error.inner.reduce((acc, error) => { - acc[error.path] = error.message - return acc - }, {}) - } - throw error - } - }, [wallet.def]) - - return ( - - {image - ? - :

{wallet.def.card.title}

} -
{wallet.def.card.subtitle}
-
{ - try { - const newConfig = !isConfigured(wallet) - - // enable wallet if wallet was just configured - if (newConfig) { - values.enabled = true - } - - await save(values, values.enabled) - - toaster.success('saved settings') - router.push('/wallets') - } catch (err) { - console.error(err) - toaster.danger(err.message || err.toString?.()) - } - }} - > - - {wallet && } - - - - - { - try { - await detach() - toaster.success('saved settings') - router.push('/wallets') - } catch (err) { - console.error(err) - const message = 'failed to detach: ' + err.message || err.toString?.() - toaster.danger(message) - } - }} - /> - -
- {wallet && } -
-
- ) -} - -function SendWarningBanner ({ walletDef }) { - const { values } = useFormikContext() - if (!canSend({ def: walletDef, config: values }) || !walletDef.requiresConfig) return null - - return -} - -function ReceiveSettings ({ walletDef }) { - const { values } = useFormikContext() - return canReceive({ def: walletDef, config: values }) && -} - -function WalletFields ({ wallet }) { - return wallet.def.fields - .map(({ - name, label = '', type, help, optional, editable, requiredWithout, - validate, clientOnly, serverOnly, generated, ...props - }, i) => { - const rawProps = { - ...props, - name, - initialValue: wallet.config?.[name], - readOnly: !SSR && isConfigured(wallet) && editable === false && !!wallet.config?.[name], - groupClassName: props.hidden ? 'd-none' : undefined, - label: label - ? ( -
- {label} - {/* help can be a string or object to customize the label */} - {help && ( - - {help.text || help} - - )} - {optional && ( - - {typeof optional === 'boolean' ? 'optional' : {optional}} - - )} -
- ) - : undefined, - required: !optional, - autoFocus: i === 0 - } - if (type === 'text') { - return - } - if (type === 'password') { - return - } - return null - }) -} diff --git a/pages/wallets/index.js b/pages/wallets/index.js index 4ea53b90..a547e9ed 100644 --- a/pages/wallets/index.js +++ b/pages/wallets/index.js @@ -1,97 +1,76 @@ import { getGetServerSideProps } from '@/api/ssrApollo' -import Layout from '@/components/layout' -import styles from '@/styles/wallet.module.css' -import Link from 'next/link' -import { useWallets } from '@/wallets/index' -import { useCallback, useEffect, useState } from 'react' -import { useIsClient } from '@/components/use-client' -import WalletCard from '@/wallets/card' -import { useToast } from '@/components/toast' -import BootstrapForm from 'react-bootstrap/Form' -import RecvIcon from '@/svgs/arrow-left-down-line.svg' -import SendIcon from '@/svgs/arrow-right-up-line.svg' -import { useRouter } from 'next/router' -import { supportsReceive, supportsSend } from '@/wallets/common' -import { useWalletIndicator } from '@/wallets/indicator' import { Button } from 'react-bootstrap' +import { useWallets, useTemplates, DndProvider, Status, useStatus } from '@/wallets/client/context' +import { WalletCard, WalletLayout, WalletLayoutHeader, WalletLayoutLink, WalletLayoutSubHeader } from '@/wallets/client/components' +import styles from '@/styles/wallet.module.css' +import { usePassphrasePrompt, useShowPassphrase, useSetWalletPriorities } from '@/wallets/client/hooks' +import { WalletSearch } from '@/wallets/client/components/search' +import { useMemo, useState } from 'react' +import { walletDisplayName } from '@/wallets/lib/util' +import Moon from '@/svgs/moon-fill.svg' export const getServerSideProps = getGetServerSideProps({ authRequired: true }) -export default function Wallet ({ ssrData }) { - const { wallets, setPriorities } = useWallets() - const toast = useToast() - const isClient = useIsClient() - const [sourceIndex, setSourceIndex] = useState(null) - const [targetIndex, setTargetIndex] = useState(null) +export default function Wallet () { + const wallets = useWallets() + const status = useStatus() + const [showWallets, setShowWallets] = useState(false) + const templates = useTemplates() + const showPassphrase = useShowPassphrase() + const passphrasePrompt = usePassphrasePrompt() + const setWalletPriorities = useSetWalletPriorities() + const [searchFilter, setSearchFilter] = useState(() => (text) => true) - const router = useRouter() - const [filter, setFilter] = useState({ - send: router.query.send === 'true', - receive: router.query.receive === 'true' - }) - - const reorder = useCallback(async (sourceIndex, targetIndex) => { - const newOrder = [...wallets.filter(w => w.config?.enabled)] - const [source] = newOrder.splice(sourceIndex, 1) - - const priorities = newOrder.slice(0, targetIndex) - .concat(source) - .concat(newOrder.slice(targetIndex)) - .map((w, i) => ({ wallet: w, priority: i })) - - await setPriorities(priorities) - }, [setPriorities, wallets]) - - const onDragStart = useCallback((i) => (e) => { - // e.dataTransfer.dropEffect = 'move' - // We can only use the DataTransfer API inside the drop event - // see https://html.spec.whatwg.org/multipage/dnd.html#security-risks-in-the-drag-and-drop-model - // e.dataTransfer.setData('text/plain', name) - // That's why we use React state instead - setSourceIndex(i) - }, [setSourceIndex]) - - const onDragEnter = useCallback((i) => (e) => { - setTargetIndex(i) - }, [setTargetIndex]) - - const onReorderError = useCallback((err) => { - console.error(err) - toast.danger('failed to reorder wallets') - }, [toast]) - - const onDragEnd = useCallback((e) => { - setSourceIndex(null) - setTargetIndex(null) - - if (sourceIndex === targetIndex) return - - reorder(sourceIndex, targetIndex).catch(onReorderError) - }, [sourceIndex, targetIndex, reorder, onReorderError]) - - const onTouchStart = useCallback((i) => (e) => { - if (sourceIndex !== null) { - reorder(sourceIndex, i).catch(onReorderError) - setSourceIndex(null) - } else { - setSourceIndex(i) + const { wallets: filteredWallets, templates: filteredTemplates } = useMemo(() => { + const walletFilter = ({ name }) => searchFilter(walletDisplayName(name)) || searchFilter(name) + return { + wallets: wallets.filter(walletFilter), + templates: templates.filter(walletFilter) } - }, [sourceIndex, reorder, onReorderError]) + }, [wallets, templates, searchFilter]) - const onFilterChange = useCallback((key) => { - return e => { - setFilter(old => ({ ...old, [key]: e.target.checked })) - router.replace({ query: { ...router.query, [key]: e.target.checked } }, undefined, { shallow: true }) - } - }, [router]) - - const indicator = useWalletIndicator() - const [showWallets, setShowWallets] = useState(!indicator) - useEffect(() => { setShowWallets(!indicator) }, [indicator]) - - if (indicator && !showWallets) { + if (status === Status.LOADING_WALLETS) { return ( - + +
+ + loading wallets +
+
+ ) + } + + if (status === Status.PASSPHRASE_REQUIRED) { + return ( + +
+ + your passphrase is required +
+
+ ) + } + + if (status === Status.WALLETS_UNAVAILABLE) { + return ( + +
+ wallets unavailable + + this device does not support storage of cryptographic keys via IndexedDB + +
+
+ ) + } + + if (status === Status.NO_WALLETS && !showWallets) { + return ( +
attach a wallet to send and receive sats
-
+ ) } return ( - -
-

wallets

-
use real bitcoin
+ +
+ wallets + use real bitcoin
- - wallet logs - + wallet logs + + settings + {showPassphrase && ( + <> + + + + )}
-
-
- receive} - onChange={onFilterChange('receive')} - checked={filter.receive} - /> - send} - onChange={onFilterChange('send')} - checked={filter.send} - /> -
- { - wallets - .filter(w => { - return (!filter.send || (filter.send && supportsSend(w))) && - (!filter.receive || (filter.receive && supportsReceive(w))) - }) - .map((w, i) => { - const draggable = isClient && w.config?.enabled - - return ( -
- -
- ) - }) - } + + {filteredWallets.length > 0 && ( + <> + +
+ {filteredWallets.map((wallet, index) => ( + + ))} +
+
+
+ + )} +
+ {filteredTemplates.map((w, i) => )}
- + ) } diff --git a/pages/wallets/logs.js b/pages/wallets/logs.js index adecc877..eeb40a52 100644 --- a/pages/wallets/logs.js +++ b/pages/wallets/logs.js @@ -1,16 +1,15 @@ -import { CenterLayout } from '@/components/layout' import { getGetServerSideProps } from '@/api/ssrApollo' -import { WalletLogs } from '@/wallets/logger' +import { WalletLayout, WalletLayoutHeader, WalletLogs } from '@/wallets/client/components' -export const getServerSideProps = getGetServerSideProps({ query: null }) +export const getServerSideProps = getGetServerSideProps({ authRequired: true }) -export default function () { +export default function WalletLogsPage () { return ( - <> - -

wallet logs

+ +
+ wallet logs - - +
+
) } diff --git a/pages/wallets/settings.js b/pages/wallets/settings.js new file mode 100644 index 00000000..629e71a5 --- /dev/null +++ b/pages/wallets/settings.js @@ -0,0 +1,185 @@ +import { getGetServerSideProps } from '@/api/ssrApollo' +import { Checkbox, Form, Input, SubmitButton } from '@/components/form' +import Info from '@/components/info' +import { isNumber } from '@/lib/format' +import { WalletLayout, WalletLayoutHeader, WalletLayoutSubHeader } from '@/wallets/client/components' +import { useMutation, useQuery } from '@apollo/client' +import Link from 'next/link' +import { useCallback, useMemo } from 'react' +import { InputGroup } from 'react-bootstrap' +import styles from '@/styles/wallet.module.css' +import classNames from 'classnames' +import { useField } from 'formik' +import { SET_WALLET_SETTINGS, WALLET_SETTINGS } from '@/wallets/client/fragments' +import { walletSettingsSchema } from '@/lib/validate' +import { useToast } from '@/components/toast' +import CancelButton from '@/components/cancel-button' + +export const getServerSideProps = getGetServerSideProps({ query: WALLET_SETTINGS, authRequired: true }) + +export default function WalletSettings ({ ssrData }) { + const { data } = useQuery(WALLET_SETTINGS) + const [setSettings] = useMutation(SET_WALLET_SETTINGS) + const { walletSettings: settings } = useMemo(() => data ?? ssrData, [data, ssrData]) + const toaster = useToast() + + const initial = { + receiveCreditsBelowSats: settings?.receiveCreditsBelowSats, + sendCreditsBelowSats: settings?.sendCreditsBelowSats, + autoWithdrawThreshold: settings?.autoWithdrawThreshold ?? 10000, + autoWithdrawMaxFeePercent: settings?.autoWithdrawMaxFeePercent ?? 1, + autoWithdrawMaxFeeTotal: settings?.autoWithdrawMaxFeeTotal ?? 1, + proxyReceive: settings?.proxyReceive + } + + const onSubmit = useCallback(async (values) => { + try { + await setSettings({ + variables: { + settings: values + } + }) + toaster.success('saved settings') + } catch (err) { + console.error(err) + toaster.danger('failed to save settings') + } + }, [toaster]) + + return ( + +
+ wallet settings + apply globally to all wallets +
+ + + + +
+ + save +
+ +
+
+ ) +} + +function CowboyCreditsSettings () { + return ( + <> + cowboy credits + sats} + type='number' + min={0} + /> + sats} + type='number' + min={0} + /> + + + ) +} + +function LightningAddressSettings () { + return ( + <> + @stacker.news lightning address + enhance privacy of my lightning address + +
    +
  • Enabling this setting hides details (ie node pubkey) of your attached wallets when anyone pays your SN lightning address or lnurl-pay
  • +
  • The lightning invoice will appear to have SN's node as the destination to preserve your wallet's privacy
  • +
  • This will incur in a 10% fee
  • +
  • Disable this setting to receive payments directly to your attached wallets (which will reveal their details to the payer)
  • +
  • Note: this privacy behavior is standard for internal zaps/payments on SN, and this setting only applies to external payments
  • +
+
+
+ } + name='proxyReceive' + groupClassName='mb-0' + /> + + ) +} + +function AutowithdrawSettings () { + const [{ value: threshold }] = useField('autoWithdrawThreshold') + const sendThreshold = Math.max(Math.floor(threshold / 10), 1) + + return ( + <> + autowithdrawal + sats} + required + type='number' + min={0} + /> + + + ) +} + +function LightningNetworkFeesSettings () { + return ( + <> + lightning network fees +
+ we'll use whichever setting is higher during{' '} + pathfinding + +
+ %} + required + type='number' + min={0} + /> + sats} + required + type='number' + min={0} + /> + + ) +} + +function Separator ({ children, className }) { + return ( +
{children}
+ ) +} diff --git a/pages/withdraw.js b/pages/withdraw.js index 1e198d21..701d07d5 100644 --- a/pages/withdraw.js +++ b/pages/withdraw.js @@ -5,7 +5,7 @@ import { useRouter } from 'next/router' import { InputGroup, Nav } from 'react-bootstrap' import styles from '@/components/user-header.module.css' import { gql, useMutation, useQuery } from '@apollo/client' -import { CREATE_WITHDRAWL, SEND_TO_LNADDR } from '@/fragments/wallet' +import { CREATE_WITHDRAWL, SEND_TO_LNADDR } from '@/fragments/invoice' import { requestProvider } from 'webln' import { useEffect, useState } from 'react' import { useMe } from '@/components/me' diff --git a/pages/withdrawals/[id].js b/pages/withdrawals/[id].js index dd39d0a1..569e4653 100644 --- a/pages/withdrawals/[id].js +++ b/pages/withdrawals/[id].js @@ -4,7 +4,7 @@ import { CopyInput, Input, InputSkeleton } from '@/components/form' import InputGroup from 'react-bootstrap/InputGroup' import InvoiceStatus from '@/components/invoice-status' import { useRouter } from 'next/router' -import { WITHDRAWL } from '@/fragments/wallet' +import { WITHDRAWL } from '@/fragments/invoice' import Link from 'next/link' import { SSR, INVOICE_RETENTION_DAYS, FAST_POLL_INTERVAL } from '@/lib/constants' import { numWithUnits } from '@/lib/format' diff --git a/prisma/migrations/20250702000000_vault_refactor/migration.sql b/prisma/migrations/20250702000000_vault_refactor/migration.sql new file mode 100644 index 00000000..f5bef060 --- /dev/null +++ b/prisma/migrations/20250702000000_vault_refactor/migration.sql @@ -0,0 +1,208 @@ +/* + Warnings: + + - A unique constraint covering the columns `[apiKeyId]` on the table `WalletBlink` will be added. If there are existing duplicate values, this will fail. + - A unique constraint covering the columns `[currencyId]` on the table `WalletBlink` will be added. If there are existing duplicate values, this will fail. + - A unique constraint covering the columns `[adminKeyId]` on the table `WalletLNbits` will be added. If there are existing duplicate values, this will fail. + - A unique constraint covering the columns `[nwcUrlId]` on the table `WalletNWC` will be added. If there are existing duplicate values, this will fail. + - A unique constraint covering the columns `[primaryPasswordId]` on the table `WalletPhoenixd` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "WalletBlink" + ADD COLUMN "apiKeyId" INTEGER, + ADD COLUMN "currencyId" INTEGER; + +-- AlterTable +ALTER TABLE "WalletLNbits" ADD COLUMN "adminKeyId" INTEGER; + +-- AlterTable +ALTER TABLE "WalletNWC" ADD COLUMN "nwcUrlId" INTEGER; + +-- AlterTable +ALTER TABLE "WalletPhoenixd" ADD COLUMN "primaryPasswordId" INTEGER; + +-- CreateTable +CREATE TABLE "Vault" ( + "id" SERIAL NOT NULL, + "iv" TEXT NOT NULL, + "value" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Vault_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, + "pairingPhraseId" INTEGER, + "localKeyId" INTEGER, + "remoteKeyId" INTEGER, + "serverHostId" INTEGER, + + CONSTRAINT "WalletLNC_pkey" PRIMARY KEY ("id") +); + +-- 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") +); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletBlink_apiKeyId_key" ON "WalletBlink"("apiKeyId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletBlink_currencyId_key" ON "WalletBlink"("currencyId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletLNbits_adminKeyId_key" ON "WalletLNbits"("adminKeyId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletNWC_nwcUrlId_key" ON "WalletNWC"("nwcUrlId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletPhoenixd_primaryPasswordId_key" ON "WalletPhoenixd"("primaryPasswordId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletLNC_walletId_key" ON "WalletLNC"("walletId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletLNC_pairingPhraseId_key" ON "WalletLNC"("pairingPhraseId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletLNC_localKeyId_key" ON "WalletLNC"("localKeyId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletLNC_remoteKeyId_key" ON "WalletLNC"("remoteKeyId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletLNC_serverHostId_key" ON "WalletLNC"("serverHostId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletWebLN_walletId_key" ON "WalletWebLN"("walletId"); + +-- AddForeignKey +ALTER TABLE "WalletLNbits" ADD CONSTRAINT "WalletLNbits_adminKeyId_fkey" FOREIGN KEY ("adminKeyId") REFERENCES "Vault"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletNWC" ADD CONSTRAINT "WalletNWC_nwcUrlId_fkey" FOREIGN KEY ("nwcUrlId") REFERENCES "Vault"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletBlink" ADD CONSTRAINT "WalletBlink_apiKeyId_fkey" FOREIGN KEY ("apiKeyId") REFERENCES "Vault"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletBlink" ADD CONSTRAINT "WalletBlink_currencyId_fkey" FOREIGN KEY ("currencyId") REFERENCES "Vault"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletPhoenixd" ADD CONSTRAINT "WalletPhoenixd_primaryPasswordId_fkey" FOREIGN KEY ("primaryPasswordId") REFERENCES "Vault"("id") ON DELETE SET NULL 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 "WalletLNC" ADD CONSTRAINT "WalletLNC_pairingPhraseId_fkey" FOREIGN KEY ("pairingPhraseId") REFERENCES "Vault"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletLNC" ADD CONSTRAINT "WalletLNC_localKeyId_fkey" FOREIGN KEY ("localKeyId") REFERENCES "Vault"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletLNC" ADD CONSTRAINT "WalletLNC_remoteKeyId_fkey" FOREIGN KEY ("remoteKeyId") REFERENCES "Vault"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletLNC" ADD CONSTRAINT "WalletLNC_serverHostId_fkey" FOREIGN KEY ("serverHostId") REFERENCES "Vault"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletWebLN" ADD CONSTRAINT "WalletWebLN_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "Wallet"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +CREATE TRIGGER wallet_lnc_as_jsonb +AFTER INSERT OR UPDATE ON "WalletLNC" +FOR EACH ROW EXECUTE PROCEDURE wallet_wallet_type_as_jsonb(); + +CREATE TRIGGER wallet_webln_as_jsonb +AFTER INSERT OR UPDATE ON "WalletWebLN" +FOR EACH ROW EXECUTE PROCEDURE wallet_wallet_type_as_jsonb(); + +CREATE OR REPLACE FUNCTION migrate_wallet_vault() +RETURNS void AS +$$ +DECLARE + vaultEntry "VaultEntry"%ROWTYPE; +BEGIN + INSERT INTO "WalletWebLN"("walletId") SELECT id FROM "Wallet" WHERE type = 'WEBLN'; + INSERT INTO "WalletLNC"("walletId") SELECT id from "Wallet" WHERE type = 'LNC'; + + FOR vaultEntry IN SELECT * FROM "VaultEntry" LOOP + DECLARE + vaultId INT; + walletType "WalletType"; + BEGIN + INSERT INTO "Vault" ("iv", "value") + VALUES (vaultEntry."iv", vaultEntry."value") + RETURNING id INTO vaultId; + + SELECT type INTO walletType + FROM "Wallet" + WHERE id = vaultEntry."walletId"; + + CASE walletType + WHEN 'LNBITS' THEN + UPDATE "WalletLNbits" + SET "adminKeyId" = vaultId + WHERE "walletId" = vaultEntry."walletId"; + WHEN 'NWC' THEN + UPDATE "WalletNWC" + SET "nwcUrlId" = vaultId + WHERE "walletId" = vaultEntry."walletId"; + WHEN 'BLINK' THEN + IF vaultEntry."key" = 'apiKey' THEN + UPDATE "WalletBlink" + SET "apiKeyId" = vaultId + WHERE "walletId" = vaultEntry."walletId"; + ELSE + UPDATE "WalletBlink" + SET "currencyId" = vaultId + WHERE "walletId" = vaultEntry."walletId"; + END IF; + WHEN 'PHOENIXD' THEN + UPDATE "WalletPhoenixd" + SET "primaryPasswordId" = vaultId + WHERE "walletId" = vaultEntry."walletId"; + WHEN 'LNC' THEN + IF vaultEntry."key" = 'pairingPhrase' THEN + UPDATE "WalletLNC" + SET "pairingPhraseId" = vaultId + WHERE "walletId" = vaultEntry."walletId"; + ELSIF vaultEntry."key" = 'localKey' THEN + UPDATE "WalletLNC" + SET "localKeyId" = vaultId + WHERE "walletId" = vaultEntry."walletId"; + ELSIF vaultEntry."key" = 'remoteKey' THEN + UPDATE "WalletLNC" + SET "remoteKeyId" = vaultId + WHERE "walletId" = vaultEntry."walletId"; + ELSIF vaultEntry."key" = 'serverHost' THEN + UPDATE "WalletLNC" + SET "serverHostId" = vaultId + WHERE "walletId" = vaultEntry."walletId"; + END IF; + END CASE; + END; + END LOOP; +END; +$$ LANGUAGE plpgsql; + +SELECT migrate_wallet_vault(); +DROP FUNCTION migrate_wallet_vault(); + +ALTER TABLE "VaultEntry" DROP CONSTRAINT "VaultEntry_userId_fkey"; +ALTER TABLE "VaultEntry" DROP CONSTRAINT "VaultEntry_walletId_fkey"; +DROP TABLE "VaultEntry"; diff --git a/prisma/migrations/20250702000001_wallet_v2/migration.sql b/prisma/migrations/20250702000001_wallet_v2/migration.sql new file mode 100644 index 00000000..3fc7ef97 --- /dev/null +++ b/prisma/migrations/20250702000001_wallet_v2/migration.sql @@ -0,0 +1,1091 @@ +-- CreateEnum +CREATE TYPE "WalletProtocolName" AS ENUM ('NWC', 'LNBITS', 'PHOENIXD', 'BLINK', 'WEBLN', 'LN_ADDR', 'LNC', 'CLN_REST', 'LND_GRPC'); + +-- CreateEnum +CREATE TYPE "WalletSendProtocolName" AS ENUM ('NWC', 'LNBITS', 'PHOENIXD', 'BLINK', 'WEBLN', 'LNC'); + +-- CreateEnum +CREATE TYPE "WalletRecvProtocolName" AS ENUM ('NWC', 'LNBITS', 'PHOENIXD', 'BLINK', 'LN_ADDR', 'CLN_REST', 'LND_GRPC'); + +-- CreateEnum +CREATE TYPE "WalletName" AS ENUM ( + 'ALBY', + 'BLINK', + 'BLIXT', + 'CASHU_ME', + 'CLN', + 'COINOS', + 'FOUNTAIN', + 'LIFPAY', + 'LNBITS', + 'LND', + 'MINIBITS', + 'NPUB_CASH', + 'PHOENIXD', + 'PRIMAL', + 'RIZFUL', + 'SHOCKWALLET', + 'SPEED', + 'STRIKE', + 'VOLTAGE', + 'WALLET_OF_SATOSHI', + 'ZBD', + 'ZEUS', + 'NWC', + 'LN_ADDR', + 'CASH_APP' +); + +-- CreateTable +CREATE TABLE "WalletTemplate" ( + "name" "WalletName" NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "sendProtocols" "WalletSendProtocolName"[], + "recvProtocols" "WalletRecvProtocolName"[], + + CONSTRAINT "WalletTemplate_pkey" PRIMARY KEY ("name") +); + +INSERT INTO "WalletTemplate" (name, "sendProtocols", "recvProtocols") VALUES + ('ALBY', + ARRAY['NWC', 'WEBLN']::"WalletSendProtocolName"[], + ARRAY['NWC', 'LN_ADDR']::"WalletRecvProtocolName"[]), + ('BLINK', + ARRAY['BLINK']::"WalletSendProtocolName"[], + ARRAY['BLINK', 'LN_ADDR']::"WalletRecvProtocolName"[]), + ('BLIXT', + ARRAY[]::"WalletSendProtocolName"[], + ARRAY['LN_ADDR']::"WalletRecvProtocolName"[]), + ('CASHU_ME', + ARRAY['NWC']::"WalletSendProtocolName"[], + ARRAY['LN_ADDR']::"WalletRecvProtocolName"[]), + ('CLN', + ARRAY[]::"WalletSendProtocolName"[], + ARRAY['CLN_REST']::"WalletRecvProtocolName"[]), + ('COINOS', + ARRAY['NWC']::"WalletSendProtocolName"[], + ARRAY['NWC', 'LN_ADDR']::"WalletRecvProtocolName"[]), + ('FOUNTAIN', + ARRAY[]::"WalletSendProtocolName"[], + ARRAY['LN_ADDR']::"WalletRecvProtocolName"[]), + ('LIFPAY', + ARRAY['NWC']::"WalletSendProtocolName"[], + ARRAY['NWC', 'LN_ADDR']::"WalletRecvProtocolName"[]), + ('LNBITS', + ARRAY['LNBITS']::"WalletSendProtocolName"[], + ARRAY['LNBITS']::"WalletRecvProtocolName"[]), + ('LND', + ARRAY['LNC']::"WalletSendProtocolName"[], + ARRAY['LND_GRPC']::"WalletRecvProtocolName"[]), + ('MINIBITS', + ARRAY['NWC']::"WalletSendProtocolName"[], + ARRAY['LN_ADDR']::"WalletRecvProtocolName"[]), + ('NPUB_CASH', + ARRAY[]::"WalletSendProtocolName"[], + ARRAY['LN_ADDR']::"WalletRecvProtocolName"[]), + ('PHOENIXD', + ARRAY['PHOENIXD']::"WalletSendProtocolName"[], + ARRAY['PHOENIXD']::"WalletRecvProtocolName"[]), + ('PRIMAL', + ARRAY['NWC']::"WalletSendProtocolName"[], + ARRAY['NWC', 'LN_ADDR']::"WalletRecvProtocolName"[]), + ('RIZFUL', + ARRAY['NWC']::"WalletSendProtocolName"[], + ARRAY['NWC', 'LN_ADDR']::"WalletRecvProtocolName"[]), + ('SHOCKWALLET', + ARRAY[]::"WalletSendProtocolName"[], + ARRAY['LN_ADDR']::"WalletRecvProtocolName"[]), + ('SPEED', + ARRAY[]::"WalletSendProtocolName"[], + ARRAY['LN_ADDR']::"WalletRecvProtocolName"[]), + ('STRIKE', + ARRAY[]::"WalletSendProtocolName"[], + ARRAY['LN_ADDR']::"WalletRecvProtocolName"[]), + ('VOLTAGE', + ARRAY[]::"WalletSendProtocolName"[], + ARRAY['LN_ADDR']::"WalletRecvProtocolName"[]), + ('WALLET_OF_SATOSHI', + ARRAY[]::"WalletSendProtocolName"[], + ARRAY['LN_ADDR']::"WalletRecvProtocolName"[]), + ('ZBD', + ARRAY[]::"WalletSendProtocolName"[], + ARRAY['LN_ADDR']::"WalletRecvProtocolName"[]), + ('ZEUS', + ARRAY[]::"WalletSendProtocolName"[], + ARRAY['LN_ADDR']::"WalletRecvProtocolName"[]), + ('NWC', + ARRAY['NWC']::"WalletSendProtocolName"[], + ARRAY['NWC']::"WalletRecvProtocolName"[]), + ('LN_ADDR', + ARRAY[]::"WalletSendProtocolName"[], + ARRAY['LN_ADDR']::"WalletRecvProtocolName"[]), + ('CASH_APP', + ARRAY[]::"WalletSendProtocolName"[], + ARRAY['LN_ADDR']::"WalletRecvProtocolName"[]); + +ALTER TABLE "Wallet" RENAME TO "WalletV1"; +ALTER TABLE "WalletV1" RENAME CONSTRAINT "Wallet_pkey" TO "WalletV1_pkey"; +ALTER INDEX "Wallet_userId_idx" RENAME TO "WalletV1_userId_idx"; + +-- CreateTable +CREATE TABLE "Wallet" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "priority" INTEGER NOT NULL DEFAULT 0, + "userId" INTEGER NOT NULL, + "templateName" "WalletName" NOT NULL, + + CONSTRAINT "Wallet_pkey" PRIMARY KEY ("id") +); + +-- CreateEnum +CREATE TYPE "WalletProtocolStatus" AS ENUM ('OK', 'WARNING', 'ERROR'); + +-- CreateTable +CREATE TABLE "WalletProtocol" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "config" JSONB, + "walletId" INTEGER NOT NULL, + "send" BOOLEAN NOT NULL, + "name" "WalletProtocolName" NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "status" "WalletProtocolStatus" NOT NULL DEFAULT 'OK', + + CONSTRAINT "WalletProtocol_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "WalletSendNWC" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "protocolId" INTEGER NOT NULL, + "urlVaultId" INTEGER NOT NULL, + + CONSTRAINT "WalletSendNWC_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "WalletSendLNbits" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "protocolId" INTEGER NOT NULL, + "url" TEXT NOT NULL, + "apiKeyVaultId" INTEGER NOT NULL, + + CONSTRAINT "WalletSendLNbits_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "WalletSendPhoenixd" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "protocolId" INTEGER NOT NULL, + "url" TEXT NOT NULL, + "apiKeyVaultId" INTEGER NOT NULL, + + CONSTRAINT "WalletSendPhoenixd_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "WalletSendBlink" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "protocolId" INTEGER NOT NULL, + "currencyVaultId" INTEGER NOT NULL, + "apiKeyVaultId" INTEGER NOT NULL, + + CONSTRAINT "WalletSendBlink_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "WalletSendWebLN" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "protocolId" INTEGER NOT NULL, + + CONSTRAINT "WalletSendWebLN_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "WalletSendLNC" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "protocolId" INTEGER NOT NULL, + "pairingPhraseVaultId" INTEGER NOT NULL, + "localKeyVaultId" INTEGER NOT NULL, + "remoteKeyVaultId" INTEGER NOT NULL, + "serverHostVaultId" INTEGER NOT NULL, + + CONSTRAINT "WalletSendLNC_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "WalletRecvNWC" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "protocolId" INTEGER NOT NULL, + "url" TEXT NOT NULL, + + CONSTRAINT "WalletRecvNWC_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "WalletRecvLNbits" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "protocolId" INTEGER NOT NULL, + "url" TEXT NOT NULL, + "apiKey" TEXT NOT NULL, + + CONSTRAINT "WalletRecvLNbits_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "WalletRecvPhoenixd" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "protocolId" INTEGER NOT NULL, + "url" TEXT NOT NULL, + "apiKey" TEXT NOT NULL, + + CONSTRAINT "WalletRecvPhoenixd_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "WalletRecvBlink" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "protocolId" INTEGER NOT NULL, + "currency" TEXT NOT NULL, + "apiKey" TEXT NOT NULL, + + CONSTRAINT "WalletRecvBlink_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "WalletRecvLightningAddress" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "protocolId" INTEGER NOT NULL, + "address" TEXT NOT NULL, + + CONSTRAINT "WalletRecvLightningAddress_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "WalletRecvCLNRest" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "protocolId" INTEGER NOT NULL, + "socket" TEXT NOT NULL, + "rune" TEXT NOT NULL, + "cert" TEXT, + + CONSTRAINT "WalletRecvCLNRest_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "WalletRecvLNDGRPC" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "protocolId" INTEGER NOT NULL, + "socket" TEXT NOT NULL, + "macaroon" TEXT NOT NULL, + "cert" TEXT, + + CONSTRAINT "WalletRecvLNDGRPC_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Wallet_userId_idx" ON "Wallet"("userId"); + +-- CreateIndex +CREATE INDEX "Wallet_templateName_idx" ON "Wallet"("templateName"); + +-- CreateIndex +CREATE INDEX "WalletProtocol_walletId_idx" ON "WalletProtocol"("walletId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletProtocol_walletId_send_name_key" ON "WalletProtocol"("walletId", "send", "name"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletSendNWC_protocolId_key" ON "WalletSendNWC"("protocolId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletSendNWC_urlVaultId_key" ON "WalletSendNWC"("urlVaultId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletSendLNbits_protocolId_key" ON "WalletSendLNbits"("protocolId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletSendLNbits_apiKeyVaultId_key" ON "WalletSendLNbits"("apiKeyVaultId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletSendPhoenixd_protocolId_key" ON "WalletSendPhoenixd"("protocolId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletSendPhoenixd_apiKeyVaultId_key" ON "WalletSendPhoenixd"("apiKeyVaultId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletSendBlink_protocolId_key" ON "WalletSendBlink"("protocolId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletSendBlink_apiKeyVaultId_key" ON "WalletSendBlink"("apiKeyVaultId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletSendBlink_currencyVaultId_key" ON "WalletSendBlink"("currencyVaultId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletSendWebLN_protocolId_key" ON "WalletSendWebLN"("protocolId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletSendLNC_protocolId_key" ON "WalletSendLNC"("protocolId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletSendLNC_pairingPhraseVaultId_key" ON "WalletSendLNC"("pairingPhraseVaultId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletSendLNC_localKeyVaultId_key" ON "WalletSendLNC"("localKeyVaultId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletSendLNC_remoteKeyVaultId_key" ON "WalletSendLNC"("remoteKeyVaultId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletSendLNC_serverHostVaultId_key" ON "WalletSendLNC"("serverHostVaultId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletRecvNWC_protocolId_key" ON "WalletRecvNWC"("protocolId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletRecvLNbits_protocolId_key" ON "WalletRecvLNbits"("protocolId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletRecvPhoenixd_protocolId_key" ON "WalletRecvPhoenixd"("protocolId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletRecvBlink_protocolId_key" ON "WalletRecvBlink"("protocolId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletRecvLightningAddress_protocolId_key" ON "WalletRecvLightningAddress"("protocolId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletRecvCLNRest_protocolId_key" ON "WalletRecvCLNRest"("protocolId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletRecvLNDGRPC_protocolId_key" ON "WalletRecvLNDGRPC"("protocolId"); + +-- AddForeignKey +ALTER TABLE "WalletProtocol" ADD CONSTRAINT "WalletProtocol_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "Wallet"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletSendNWC" ADD CONSTRAINT "WalletSendNWC_protocolId_fkey" FOREIGN KEY ("protocolId") REFERENCES "WalletProtocol"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletSendNWC" ADD CONSTRAINT "WalletSendNWC_urlVaultId_fkey" FOREIGN KEY ("urlVaultId") REFERENCES "Vault"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletSendLNbits" ADD CONSTRAINT "WalletSendLNbits_protocolId_fkey" FOREIGN KEY ("protocolId") REFERENCES "WalletProtocol"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletSendLNbits" ADD CONSTRAINT "WalletSendLNbits_apiKeyVaultId_fkey" FOREIGN KEY ("apiKeyVaultId") REFERENCES "Vault"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletSendPhoenixd" ADD CONSTRAINT "WalletSendPhoenixd_protocolId_fkey" FOREIGN KEY ("protocolId") REFERENCES "WalletProtocol"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletSendPhoenixd" ADD CONSTRAINT "WalletSendPhoenixd_apiKeyVaultId_fkey" FOREIGN KEY ("apiKeyVaultId") REFERENCES "Vault"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletSendBlink" ADD CONSTRAINT "WalletSendBlink_protocolId_fkey" FOREIGN KEY ("protocolId") REFERENCES "WalletProtocol"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletSendBlink" ADD CONSTRAINT "WalletSendBlink_currencyVaultId_fkey" FOREIGN KEY ("currencyVaultId") REFERENCES "Vault"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletSendBlink" ADD CONSTRAINT "WalletSendBlink_apiKeyVaultId_fkey" FOREIGN KEY ("apiKeyVaultId") REFERENCES "Vault"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletSendWebLN" ADD CONSTRAINT "WalletSendWebLN_protocolId_fkey" FOREIGN KEY ("protocolId") REFERENCES "WalletProtocol"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletSendLNC" ADD CONSTRAINT "WalletSendLNC_protocolId_fkey" FOREIGN KEY ("protocolId") REFERENCES "WalletProtocol"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletSendLNC" ADD CONSTRAINT "WalletSendLNC_pairingPhraseVaultId_fkey" FOREIGN KEY ("pairingPhraseVaultId") REFERENCES "Vault"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletSendLNC" ADD CONSTRAINT "WalletSendLNC_localKeyVaultId_fkey" FOREIGN KEY ("localKeyVaultId") REFERENCES "Vault"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletSendLNC" ADD CONSTRAINT "WalletSendLNC_remoteKeyVaultId_fkey" FOREIGN KEY ("remoteKeyVaultId") REFERENCES "Vault"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletSendLNC" ADD CONSTRAINT "WalletSendLNC_serverHostVaultId_fkey" FOREIGN KEY ("serverHostVaultId") REFERENCES "Vault"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletRecvNWC" ADD CONSTRAINT "WalletRecvNWC_protocolId_fkey" FOREIGN KEY ("protocolId") REFERENCES "WalletProtocol"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletRecvLNbits" ADD CONSTRAINT "WalletRecvLNbits_protocolId_fkey" FOREIGN KEY ("protocolId") REFERENCES "WalletProtocol"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletRecvPhoenixd" ADD CONSTRAINT "WalletRecvPhoenixd_protocolId_fkey" FOREIGN KEY ("protocolId") REFERENCES "WalletProtocol"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletRecvBlink" ADD CONSTRAINT "WalletRecvBlink_protocolId_fkey" FOREIGN KEY ("protocolId") REFERENCES "WalletProtocol"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletRecvLightningAddress" ADD CONSTRAINT "WalletRecvLightningAddress_protocolId_fkey" FOREIGN KEY ("protocolId") REFERENCES "WalletProtocol"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletRecvCLNRest" ADD CONSTRAINT "WalletRecvCLNRest_protocolId_fkey" FOREIGN KEY ("protocolId") REFERENCES "WalletProtocol"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletRecvLNDGRPC" ADD CONSTRAINT "WalletRecvLNDGRPC_protocolId_fkey" FOREIGN KEY ("protocolId") REFERENCES "WalletProtocol"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Wallet" ADD CONSTRAINT "Wallet_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Wallet" ADD CONSTRAINT "Wallet_templateName_fkey" FOREIGN KEY ("templateName") REFERENCES "WalletTemplate"("name") ON DELETE CASCADE ON UPDATE CASCADE; + +CREATE OR REPLACE FUNCTION wallet_check_support() +RETURNS TRIGGER AS $$ +DECLARE + template "WalletTemplate"; +BEGIN + SELECT t.* INTO template + FROM "Wallet" w + JOIN "WalletTemplate" t ON w."templateName" = t.name + WHERE w.id = NEW."walletId"; + + IF NEW."send" THEN + IF NOT NEW."name"::text::"WalletSendProtocolName" = ANY(template."sendProtocols") THEN + RAISE EXCEPTION 'Wallet % does not support send protocol %', template.name, NEW."name"; + END IF; + ELSE + IF NOT NEW."name"::text::"WalletRecvProtocolName" = ANY(template."recvProtocols") THEN + RAISE EXCEPTION 'Wallet % does not support receive protocol %', template.name, NEW."name"; + END IF; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE CONSTRAINT TRIGGER wallet_check_support + AFTER INSERT OR UPDATE ON "WalletProtocol" + FOR EACH ROW + EXECUTE FUNCTION wallet_check_support(); + +CREATE OR REPLACE FUNCTION wallet_to_jsonb() +RETURNS TRIGGER AS $$ +DECLARE + wallet jsonb; + vault jsonb; + col_name text; + vault_id int; + base_name text; +BEGIN + wallet := to_jsonb(NEW); + + FOR col_name IN + SELECT key::text + FROM jsonb_each(wallet) + WHERE key::text LIKE '%VaultId' + LOOP + vault_id := (wallet->>col_name)::int; + -- remove 'VaultId' suffix + base_name := substring(col_name from 1 for length(col_name)-7); + + SELECT jsonb_build_object('id', v.id, 'iv', v.iv, 'value', v.value) INTO vault + FROM "Vault" v + WHERE v.id = vault_id; + + IF vault IS NOT NULL THEN + wallet := jsonb_set(wallet, array[base_name], vault) - col_name; + END IF; + END LOOP; + + UPDATE "WalletProtocol" + SET + config = wallet, + updated_at = NOW() + WHERE id = NEW."protocolId"; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER wallet_to_jsonb + AFTER INSERT OR UPDATE ON "WalletSendNWC" + FOR EACH ROW + EXECUTE PROCEDURE wallet_to_jsonb(); + +CREATE TRIGGER wallet_to_jsonb + AFTER INSERT OR UPDATE ON "WalletSendLNbits" + FOR EACH ROW + EXECUTE PROCEDURE wallet_to_jsonb(); + +CREATE TRIGGER wallet_to_jsonb + AFTER INSERT OR UPDATE ON "WalletSendPhoenixd" + FOR EACH ROW + EXECUTE PROCEDURE wallet_to_jsonb(); + +CREATE TRIGGER wallet_to_jsonb + AFTER INSERT OR UPDATE ON "WalletSendBlink" + FOR EACH ROW + EXECUTE PROCEDURE wallet_to_jsonb(); + +CREATE TRIGGER wallet_to_jsonb + AFTER INSERT OR UPDATE ON "WalletSendWebLN" + FOR EACH ROW + EXECUTE PROCEDURE wallet_to_jsonb(); + +CREATE TRIGGER wallet_to_jsonb + AFTER INSERT OR UPDATE ON "WalletSendLNC" + FOR EACH ROW + EXECUTE PROCEDURE wallet_to_jsonb(); + +CREATE TRIGGER wallet_to_jsonb + AFTER INSERT OR UPDATE ON "WalletRecvNWC" + FOR EACH ROW + EXECUTE PROCEDURE wallet_to_jsonb(); + +CREATE TRIGGER wallet_to_jsonb + AFTER INSERT OR UPDATE ON "WalletRecvLNbits" + FOR EACH ROW + EXECUTE PROCEDURE wallet_to_jsonb(); + +CREATE TRIGGER wallet_to_jsonb + AFTER INSERT OR UPDATE ON "WalletRecvPhoenixd" + FOR EACH ROW + EXECUTE PROCEDURE wallet_to_jsonb(); + +CREATE TRIGGER wallet_to_jsonb + AFTER INSERT OR UPDATE ON "WalletRecvBlink" + FOR EACH ROW + EXECUTE PROCEDURE wallet_to_jsonb(); + +CREATE TRIGGER wallet_to_jsonb + AFTER INSERT OR UPDATE ON "WalletRecvLightningAddress" + FOR EACH ROW + EXECUTE PROCEDURE wallet_to_jsonb(); + +CREATE TRIGGER wallet_to_jsonb + AFTER INSERT OR UPDATE ON "WalletRecvCLNRest" + FOR EACH ROW + EXECUTE PROCEDURE wallet_to_jsonb(); + +CREATE TRIGGER wallet_to_jsonb + AFTER INSERT OR UPDATE ON "WalletRecvLNDGRPC" + FOR EACH ROW + EXECUTE PROCEDURE wallet_to_jsonb(); + +CREATE OR REPLACE FUNCTION wallet_clear_vault() +RETURNS TRIGGER AS $$ +DECLARE + wallet jsonb; + col_name text; + vault_id int; +BEGIN + wallet := to_jsonb(OLD); + + FOR col_name IN + SELECT key::text + FROM jsonb_each(wallet) + WHERE key::text LIKE '%VaultId' + LOOP + vault_id := (wallet->>col_name)::int; + DELETE FROM "Vault" WHERE id = vault_id; + END LOOP; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER wallet_clear_vault + AFTER DELETE ON "WalletSendNWC" + FOR EACH ROW + EXECUTE PROCEDURE wallet_clear_vault(); + +CREATE TRIGGER wallet_clear_vault + AFTER DELETE ON "WalletSendLNbits" + FOR EACH ROW + EXECUTE PROCEDURE wallet_clear_vault(); + +CREATE TRIGGER wallet_clear_vault + AFTER DELETE ON "WalletSendPhoenixd" + FOR EACH ROW + EXECUTE PROCEDURE wallet_clear_vault(); + +CREATE TRIGGER wallet_clear_vault + AFTER DELETE ON "WalletSendBlink" + FOR EACH ROW + EXECUTE PROCEDURE wallet_clear_vault(); + +CREATE TRIGGER wallet_clear_vault + AFTER DELETE ON "WalletSendWebLN" + FOR EACH ROW + EXECUTE PROCEDURE wallet_clear_vault(); + +CREATE TRIGGER wallet_clear_vault + AFTER DELETE ON "WalletSendLNC" + FOR EACH ROW + EXECUTE PROCEDURE wallet_clear_vault(); + +CREATE OR REPLACE FUNCTION wallet_updated_at_trigger() RETURNS TRIGGER AS $$ +DECLARE + user_id INT; +BEGIN + IF TG_TABLE_NAME = 'WalletProtocol' THEN + SELECT w."userId" INTO user_id + FROM "Wallet" w + WHERE w.id = CASE + WHEN TG_OP = 'DELETE' THEN OLD."walletId" + ELSE NEW."walletId" + END; + ELSE + SELECT w."userId" INTO user_id + FROM "Wallet" w + WHERE w.id = NEW.id; + END IF; + + UPDATE "users" u + SET "walletsUpdatedAt" = NOW() + WHERE u.id = user_id; + + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE TRIGGER wallet_updated_at_trigger +AFTER INSERT OR UPDATE OR DELETE ON "WalletProtocol" +FOR EACH ROW EXECUTE PROCEDURE wallet_updated_at_trigger(); + +CREATE OR REPLACE TRIGGER wallet_updated_at_trigger +AFTER INSERT OR UPDATE OR DELETE ON "Wallet" +FOR EACH ROW EXECUTE PROCEDURE wallet_updated_at_trigger(); + +CREATE OR REPLACE FUNCTION user_auto_withdraw() RETURNS TRIGGER AS $$ +DECLARE +BEGIN + INSERT INTO pgboss.job (name, data) + SELECT 'autoWithdraw', jsonb_build_object('id', NEW.id) + -- only if there isn't already a pending job for this user + WHERE NOT EXISTS ( + SELECT * + FROM pgboss.job + WHERE name = 'autoWithdraw' + AND data->>'id' = NEW.id::TEXT + AND state = 'created' + ) + AND EXISTS ( + SELECT * + FROM "Wallet" w + JOIN "WalletProtocol" wp ON w.id = wp."walletId" + WHERE w."userId" = NEW.id + AND wp."enabled" = true + AND wp.send = false + ); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION get_or_create_wallet( + user_id INT, + template_name "WalletName", + priority INT +) +RETURNS INT AS +$$ +DECLARE + walletId INT; +BEGIN + SELECT w.id INTO walletId + FROM "Wallet" w + WHERE w."userId" = user_id AND w."templateName" = template_name; + + IF NOT FOUND THEN + walletId := create_wallet(user_id, template_name, priority); + END IF; + + RETURN walletId; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION create_wallet( + user_id INT, + template_name "WalletName", + priority INT +) +RETURNS INT AS +$$ +DECLARE + walletId INT; +BEGIN + INSERT INTO "Wallet" ("userId", "templateName", "priority") + SELECT user_id, template_name, priority + FROM "WalletTemplate" t + WHERE t.name = template_name + RETURNING id INTO walletId; + + RETURN walletId; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION create_wallet_protocol( + id INT, + wallet_id INT, + send BOOLEAN, + protocol_name "WalletProtocolName", + enabled BOOLEAN +) +RETURNS INT AS +$$ +DECLARE + protocolId INT; +BEGIN + INSERT INTO "WalletProtocol" ("id", "walletId", "send", "name", "enabled") + VALUES (CASE WHEN send THEN nextval('"WalletProtocol_id_seq"') ELSE id END, wallet_id, send, protocol_name, enabled) + RETURNING "WalletProtocol"."id" INTO protocolId; + + RETURN protocolId; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION wallet_v2_migration() +RETURNS void AS +$$ +DECLARE + row RECORD; +BEGIN + -- In the old wallet schema, send and receive were stored in the same table that linked to a row in the Wallet table. + -- Foreign keys in other tables pointed to that row in the Wallet table. + -- In the new schema, send and receive are stored in separate tables and they point to individual rows in the WalletProtocol table. + -- Therefore, to be able to point the foreign keys to the new WalletProtocol table, we need to keep the same id, but only for the receive wallets + -- because that's what the foreign keys were pointing to in the old schema. + -- To avoid generating an id via the sequence that we already inserted manually, we let the sequence start at the highest Wallet id of the old schema. + PERFORM setval('"WalletProtocol_id_seq"', (SELECT MAX(id) FROM "WalletV1")); + + FOR row IN + SELECT w1.*, w2."userId", w2."priority", w2."enabled" + FROM "WalletLNbits" w1 + JOIN "WalletV1" w2 ON w1."walletId" = w2.id + LOOP + DECLARE + walletId INT; + protocolId INT; + BEGIN + walletId := get_or_create_wallet(row."userId", 'LNBITS', row."priority"); + + IF row."adminKeyId" IS NOT NULL THEN + protocolId := create_wallet_protocol(row."walletId", walletId, true, 'LNBITS', row."enabled"); + INSERT INTO "WalletSendLNbits" ("protocolId", "url", "apiKeyVaultId") + VALUES (protocolId, row."url", row."adminKeyId"); + END IF; + + IF NULLIF(row."invoiceKey", '') IS NOT NULL THEN + protocolId := create_wallet_protocol(row."walletId", walletId, false, 'LNBITS', row."enabled"); + INSERT INTO "WalletRecvLNbits" ("protocolId", "url", "apiKey") + VALUES (protocolId, row."url", row."invoiceKey"); + END IF; + END; + END LOOP; + + FOR row IN + SELECT w1.*, w2."userId", w2."userId", w2."priority", w2."enabled" + FROM "WalletPhoenixd" w1 + JOIN "WalletV1" w2 ON w1."walletId" = w2.id + LOOP + DECLARE + walletId INT; + protocolId INT; + BEGIN + walletId := get_or_create_wallet(row."userId", 'PHOENIXD', row."priority"); + + IF row."primaryPasswordId" IS NOT NULL THEN + protocolId := create_wallet_protocol(row."walletId", walletId, true, 'PHOENIXD', row."enabled"); + INSERT INTO "WalletSendPhoenixd" ("protocolId", "url", "apiKeyVaultId") + VALUES (protocolId, row."url", row."primaryPasswordId"); + END IF; + + IF NULLIF(row."secondaryPassword", '') IS NOT NULL THEN + protocolId := create_wallet_protocol(row."walletId", walletId, false, 'PHOENIXD', row."enabled"); + INSERT INTO "WalletRecvPhoenixd" ("protocolId", "url", "apiKey") + VALUES (protocolId, row."url", row."secondaryPassword"); + END IF; + END; + END LOOP; + + FOR row IN + SELECT w1.*, w2."userId", w2."userId", w2."priority", w2."enabled" + FROM "WalletBlink" w1 + JOIN "WalletV1" w2 ON w1."walletId" = w2.id + LOOP + DECLARE + walletId INT; + protocolId INT; + BEGIN + walletId := get_or_create_wallet(row."userId", 'BLINK', row."priority"); + + IF row."apiKeyId" IS NOT NULL AND row."currencyId" IS NOT NULL THEN + protocolId := create_wallet_protocol(row."walletId", walletId, true, 'BLINK', row."enabled"); + INSERT INTO "WalletSendBlink" ("protocolId", "apiKeyVaultId", "currencyVaultId") + VALUES (protocolId, row."apiKeyId", row."currencyId"); + END IF; + + IF NULLIF(row."apiKeyRecv", '') IS NOT NULL AND NULLIF(row."currencyRecv", '') IS NOT NULL THEN + protocolId := create_wallet_protocol(row."walletId", walletId, false, 'BLINK', row."enabled"); + INSERT INTO "WalletRecvBlink" ("protocolId", "apiKey", "currency") + VALUES (protocolId, row."apiKeyRecv", row."currencyRecv"); + END IF; + END; + END LOOP; + + FOR row IN + SELECT w1.*, w2."userId", w2."userId", w2."priority", w2."enabled" + FROM "WalletLND" w1 + JOIN "WalletV1" w2 ON w1."walletId" = w2.id + LOOP + DECLARE + walletId INT; + protocolId INT; + BEGIN + walletId := get_or_create_wallet(row."userId", 'LND', row."priority"); + + protocolId := create_wallet_protocol(row."walletId", walletId, false, 'LND_GRPC', row."enabled"); + INSERT INTO "WalletRecvLNDGRPC" ("protocolId", "socket", "macaroon", "cert") + VALUES (protocolId, row."socket", row."macaroon", row."cert"); + END; + END LOOP; + + FOR row IN + SELECT w1.*, w2."userId", w2."userId", w2."priority", w2."enabled" + FROM "WalletLNC" w1 + JOIN "WalletV1" w2 ON w1."walletId" = w2.id + LOOP + DECLARE + walletId INT; + protocolId INT; + BEGIN + walletId := get_or_create_wallet(row."userId", 'LND', row."priority"); + + protocolId := create_wallet_protocol(row."walletId", walletId, true, 'LNC', row."enabled"); + INSERT INTO "WalletSendLNC" ("protocolId", "pairingPhraseVaultId", "localKeyVaultId", "remoteKeyVaultId", "serverHostVaultId") + VALUES (protocolId, row."pairingPhraseId", row."localKeyId", row."remoteKeyId", row."serverHostId"); + END; + END LOOP; + + FOR row IN + SELECT w1.*, w2."userId", w2."userId", w2."priority", w2."enabled" + FROM "WalletCLN" w1 + JOIN "WalletV1" w2 ON w1."walletId" = w2.id + LOOP + DECLARE + walletId INT; + protocolId INT; + BEGIN + walletId := get_or_create_wallet(row."userId", 'CLN', row."priority"); + + protocolId := create_wallet_protocol(row."walletId", walletId, false, 'CLN_REST', row."enabled"); + INSERT INTO "WalletRecvCLNRest" ("protocolId", "socket", "rune", "cert") + VALUES (protocolId, row."socket", row."rune", row."cert"); + END; + END LOOP; + + FOR row IN + SELECT w1.*, w2."userId", w2."userId", w2."priority", w2."enabled" + FROM "WalletNWC" w1 + JOIN "WalletV1" w2 ON w1."walletId" = w2.id + LOOP + DECLARE + walletId INT; + protocolId INT; + relay TEXT; + walletName "WalletName"; + BEGIN + relay := substring(row."nwcUrlRecv" from 'relay=([^&]+)'); + + IF relay LIKE '%getalby.com%' THEN + walletName := 'ALBY'; + ELSIF relay LIKE '%rizful.com%' THEN + walletName := 'RIZFUL'; + ELSIF relay LIKE '%primal.net%' THEN + walletName := 'PRIMAL'; + ELSIF relay LIKE '%coinos.io%' THEN + walletName := 'COINOS'; + ELSE + walletName := 'NWC'; + END IF; + + walletId := get_or_create_wallet(row."userId", walletName, row."priority"); + + -- we assume here that the wallet to receive is the same as the wallet to send + -- since we can't check which relay is used for the send connection because it's encrypted. + -- but in 99% if not 100% of the cases, it's the same wallet. + IF NULLIF(row."nwcUrlRecv", '') IS NOT NULL THEN + protocolId := create_wallet_protocol(row."walletId", walletId, false, 'NWC', row."enabled"); + INSERT INTO "WalletRecvNWC" ("protocolId", "url") + VALUES (protocolId, row."nwcUrlRecv"); + END IF; + + IF row."nwcUrlId" IS NOT NULL THEN + protocolId := create_wallet_protocol(row."walletId", walletId, true, 'NWC', row."enabled"); + INSERT INTO "WalletSendNWC" ("protocolId", "urlVaultId") + VALUES (protocolId, row."nwcUrlId"); + END IF; + END; + END LOOP; + + FOR row IN + SELECT w1.*, w2."userId", w2."userId", w2."priority", w2."enabled" + FROM "WalletLightningAddress" w1 + JOIN "WalletV1" w2 ON w1."walletId" = w2.id + LOOP + DECLARE + walletId INT; + protocolId INT; + domain TEXT; + walletName "WalletName"; + BEGIN + domain := split_part(row."address", '@', 2); + + IF domain LIKE '%walletofsatoshi.com' THEN + walletName := 'WALLET_OF_SATOSHI'; + ELSIF domain LIKE '%getalby.com' THEN + walletName := 'ALBY'; + ELSIF domain LIKE '%coinos.io' THEN + walletName := 'COINOS'; + ELSIF domain LIKE '%speed.app' OR domain LIKE '%tryspeed.com' THEN + walletName := 'SPEED'; + ELSIF domain LIKE '%blink.sv' THEN + walletName := 'BLINK'; + ELSIF domain LIKE '%zbd.gg' THEN + walletName := 'ZBD'; + ELSIF domain LIKE '%strike.me' THEN + walletName := 'STRIKE'; + ELSIF domain LIKE '%primal.net' THEN + walletName := 'PRIMAL'; + ELSIF domain LIKE '%minibits.cash' THEN + walletName := 'MINIBITS'; + ELSIF domain LIKE '%npub.cash' THEN + walletName := 'NPUB_CASH'; + ELSIF domain LIKE '%zeuspay.com' THEN + walletName := 'ZEUS'; + ELSIF domain LIKE '%fountain.fm' THEN + walletName := 'FOUNTAIN'; + ELSIF domain LIKE '%lifpay.me' THEN + walletName := 'LIFPAY'; + ELSIF domain LIKE '%rizful.com' THEN + walletName := 'RIZFUL'; + ELSIF domain LIKE '%vlt.ge' THEN + walletName := 'VOLTAGE'; + ELSIF domain LIKE '%blixtwallet.com' THEN + walletName := 'BLIXT'; + ELSIF domain LIKE '%shockwallet.app' THEN + walletName := 'SHOCKWALLET'; + ELSE + walletName := 'LN_ADDR'; + END IF; + + walletId := get_or_create_wallet(row."userId", walletName, row."priority"); + + protocolId := create_wallet_protocol(row."walletId", walletId, false, 'LN_ADDR', row."enabled"); + INSERT INTO "WalletRecvLightningAddress" ("protocolId", "address") + VALUES (protocolId, row."address"); + END; + END LOOP; + + FOR row IN + SELECT w1.*, w2."userId", w2."userId", w2."priority", w2."enabled" + FROM "WalletWebLN" w1 + JOIN "WalletV1" w2 ON w1."walletId" = w2.id + LOOP + DECLARE + walletId INT; + protocolId INT; + BEGIN + walletId := get_or_create_wallet(row."userId", 'ALBY', row."priority"); + + protocolId := create_wallet_protocol(row."walletId", walletId, true, 'WEBLN', row."enabled"); + INSERT INTO "WalletSendWebLN" ("protocolId") + VALUES (protocolId); + END; + END LOOP; +END; +$$ LANGUAGE plpgsql; + +SELECT wallet_v2_migration(); + +DROP FUNCTION wallet_v2_migration(); +DROP FUNCTION get_or_create_wallet(INT, "WalletName", INT); +DROP FUNCTION create_wallet(INT, "WalletName", INT); +DROP FUNCTION create_wallet_protocol(INT, INT, BOOLEAN, "WalletProtocolName", BOOLEAN); + +-- drop old tables +DROP TABLE "WalletBlink"; +DROP TABLE "WalletCLN"; +DROP TABLE "WalletLNC"; +DROP TABLE "WalletLND"; +DROP TABLE "WalletLNbits"; +DROP TABLE "WalletLightningAddress"; +DROP TABLE "WalletNWC"; +DROP TABLE "WalletPhoenixd"; +DROP TABLE "WalletWebLN"; + +-- update foreign keys +ALTER TABLE "Withdrawl" DROP CONSTRAINT "Withdrawl_walletId_fkey"; +ALTER TABLE "Withdrawl" RENAME COLUMN "walletId" TO "protocolId"; +ALTER TABLE "Withdrawl" ADD CONSTRAINT "Withdrawl_protocolId_fkey" FOREIGN KEY ("protocolId") REFERENCES "WalletProtocol"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER INDEX "Withdrawl_walletId_idx" RENAME TO "Withdrawl_protocolId_idx"; + +ALTER TABLE "DirectPayment" DROP CONSTRAINT "DirectPayment_walletId_fkey"; +ALTER TABLE "DirectPayment" RENAME COLUMN "walletId" TO "protocolId"; +ALTER TABLE "DirectPayment" ADD CONSTRAINT "DirectPayment_protocolId_fkey" FOREIGN KEY ("protocolId") REFERENCES "WalletProtocol"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "InvoiceForward" DROP CONSTRAINT "InvoiceForward_walletId_fkey"; +ALTER TABLE "InvoiceForward" RENAME COLUMN "walletId" TO "protocolId"; +ALTER TABLE "InvoiceForward" ADD CONSTRAINT "InvoiceForward_protocolId_fkey" FOREIGN KEY ("protocolId") REFERENCES "WalletProtocol"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER INDEX "InvoiceForward_walletId_idx" RENAME TO "InvoiceForward_protocolId_idx"; + +-- now drop Wallet table because nothing points to it anymore +DROP TABLE "WalletV1"; + +-- drop old function used for the JSON trigger +DROP FUNCTION wallet_wallet_type_as_jsonb; + +-- wallet logs now point to the new WalletProtocol table instead of to the old WalletType enum +ALTER TABLE "WalletLog" + DROP COLUMN "wallet", + ADD COLUMN "protocolId" INTEGER; + +DROP TYPE "WalletType"; +ALTER TABLE "WalletLog" ADD CONSTRAINT "WalletLog_protocolId_fkey" FOREIGN KEY ("protocolId") REFERENCES "WalletProtocol"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AlterTable +ALTER TABLE "users" ADD COLUMN "showPassphrase" BOOLEAN NOT NULL DEFAULT true; + +-- Update LogLevel enum to be more consistent with wallet logger API +ALTER TYPE "LogLevel" RENAME TO "LogLevelV1"; +CREATE TYPE "LogLevel" AS ENUM ('OK', 'DEBUG', 'INFO', 'WARNING', 'ERROR'); +ALTER TABLE "WalletLog" ALTER COLUMN "level" TYPE "LogLevel" USING (CASE WHEN "level"::text = 'SUCCESS' THEN 'OK'::"LogLevel" WHEN "level"::text = 'WARN' THEN 'WARNING'::"LogLevel" ELSE "level"::text::"LogLevel" END); +ALTER TABLE "Log" ALTER COLUMN "level" TYPE "LogLevel" USING (CASE WHEN "level"::text = 'SUCCESS' THEN 'OK'::"LogLevel" WHEN "level"::text = 'WARN' THEN 'WARNING'::"LogLevel" ELSE "level"::text::"LogLevel" END); +DROP TYPE "LogLevelV1"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2be2378f..e6a0ca17 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -145,8 +145,8 @@ model User { oneDayReferrals OneDayReferral[] @relation("OneDayReferral_referrer") oneDayReferrees OneDayReferral[] @relation("OneDayReferral_referrees") vaultKeyHash String @default("") + showPassphrase Boolean @default(true) walletsUpdatedAt DateTime? - vaultEntries VaultEntry[] @relation("VaultEntries") proxyReceive Boolean @default(true) DirectPaymentReceived DirectPayment[] @relation("DirectPaymentReceived") DirectPaymentSent DirectPayment[] @relation("DirectPaymentSent") @@ -202,157 +202,42 @@ model UserSubTrust { @@id([userId, subName]) } -enum WalletType { - LIGHTNING_ADDRESS - LND - CLN - LNBITS - NWC - PHOENIXD - BLINK - LNC - WEBLN -} - -model Wallet { +model Vault { id Int @id @default(autoincrement()) - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") - userId Int - label String? - enabled Boolean @default(true) - priority Int @default(0) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - // NOTE: this denormalized json field exists to make polymorphic joins efficient - // when reading wallets ... it is populated by a trigger when wallet descendants update - // otherwise reading wallets would require a join on every descendant table - // which might not be numerous for wallets but would be for other tables - // so this is a pattern we use only to be consistent with future polymorphic tables - // because it gives us fast reads and type safe writes - type WalletType - wallet Json? @db.JsonB - walletLightningAddress WalletLightningAddress? - walletLND WalletLND? - walletCLN WalletCLN? - walletLNbits WalletLNbits? - walletNWC WalletNWC? - walletPhoenixd WalletPhoenixd? - walletBlink WalletBlink? - - vaultEntries VaultEntry[] @relation("VaultEntries") - withdrawals Withdrawl[] - InvoiceForward InvoiceForward[] - DirectPayment DirectPayment[] - - @@unique([userId, type]) - @@index([userId]) - @@index([priority]) -} - -model VaultEntry { - id Int @id @default(autoincrement()) - key String @db.Text iv String @db.Text value String @db.Text - userId Int - walletId Int? - user User @relation(fields: [userId], references: [id], onDelete: Cascade, name: "VaultEntries") - wallet Wallet? @relation(fields: [walletId], references: [id], onDelete: Cascade, name: "VaultEntries") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @default(now()) @updatedAt @map("updated_at") - @@unique([userId, key]) - @@index([walletId]) + walletSendNWC WalletSendNWC? + walletSendLNbits WalletSendLNbits? + walletSendPhoenixd WalletSendPhoenixd? + walletSendBlinkApiKey WalletSendBlink? @relation("blinkApiKeySend") + walletSendBlinkCurrency WalletSendBlink? @relation("blinkCurrencySend") + walletSendLNCPairingPhrase WalletSendLNC? @relation("lncPairingPhrase") + walletSendLNCLocalKey WalletSendLNC? @relation("lncLocalKey") + walletSendLNCRemoteKey WalletSendLNC? @relation("lncRemoteKey") + walletSendLNCServerHost WalletSendLNC? @relation("lncServerHost") } model WalletLog { - id Int @id @default(autoincrement()) - createdAt DateTime @default(now()) @map("created_at") + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") userId Int - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - wallet WalletType? + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + protocolId Int? + protocol WalletProtocol? @relation(fields: [protocolId], references: [id], onDelete: Cascade) level LogLevel message String invoiceId Int? - invoice Invoice? @relation(fields: [invoiceId], references: [id]) + invoice Invoice? @relation(fields: [invoiceId], references: [id]) withdrawalId Int? - withdrawal Withdrawl? @relation(fields: [withdrawalId], references: [id]) - context Json? @db.JsonB + withdrawal Withdrawl? @relation(fields: [withdrawalId], references: [id]) + context Json? @db.JsonB @@index([userId, createdAt]) } -model WalletLightningAddress { - id Int @id @default(autoincrement()) - walletId Int @unique - wallet Wallet @relation(fields: [walletId], references: [id], onDelete: Cascade) - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") - address String -} - -model WalletLND { - id Int @id @default(autoincrement()) - walletId Int @unique - wallet Wallet @relation(fields: [walletId], references: [id], onDelete: Cascade) - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") - socket String - macaroon String - cert String? -} - -model WalletCLN { - id Int @id @default(autoincrement()) - walletId Int @unique - wallet Wallet @relation(fields: [walletId], references: [id], onDelete: Cascade) - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") - socket String - rune String - cert String? -} - -model WalletLNbits { - id Int @id @default(autoincrement()) - walletId Int @unique - wallet Wallet @relation(fields: [walletId], references: [id], onDelete: Cascade) - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") - url String - invoiceKey String? -} - -model WalletNWC { - id Int @id @default(autoincrement()) - walletId Int @unique - wallet Wallet @relation(fields: [walletId], references: [id], onDelete: Cascade) - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") - nwcUrlRecv String? -} - -model WalletBlink { - id Int @id @default(autoincrement()) - walletId Int @unique - wallet Wallet @relation(fields: [walletId], references: [id], onDelete: Cascade) - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") - apiKeyRecv String? - currencyRecv String? -} - -model WalletPhoenixd { - id Int @id @default(autoincrement()) - walletId Int @unique - wallet Wallet @relation(fields: [walletId], references: [id], onDelete: Cascade) - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") - url String - secondaryPassword String? -} - model Mute { muterId Int mutedId Int @@ -1005,22 +890,22 @@ model Invoice { } model DirectPayment { - id Int @id @default(autoincrement()) - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") senderId Int? receiverId Int? - preimage String? @unique + preimage String? @unique bolt11 String? - hash String? @unique + hash String? @unique desc String? comment String? lud18Data Json? msats BigInt - walletId Int? - sender User? @relation("DirectPaymentSent", fields: [senderId], references: [id], onDelete: Cascade) - receiver User? @relation("DirectPaymentReceived", fields: [receiverId], references: [id], onDelete: Cascade) - wallet Wallet? @relation(fields: [walletId], references: [id], onDelete: SetNull) + protocolId Int? + sender User? @relation("DirectPaymentSent", fields: [senderId], references: [id], onDelete: Cascade) + receiver User? @relation("DirectPaymentReceived", fields: [receiverId], references: [id], onDelete: Cascade) + protocol WalletProtocol? @relation(fields: [protocolId], references: [id], onDelete: SetNull) @@index([createdAt]) @@index([senderId]) @@ -1033,7 +918,7 @@ model InvoiceForward { updatedAt DateTime @default(now()) @updatedAt @map("updated_at") bolt11 String maxFeeMsats Int - walletId Int + protocolId Int // we get these values when the invoice is held expiryHeight Int? @@ -1043,12 +928,12 @@ model InvoiceForward { invoiceId Int @unique withdrawlId Int? @unique - invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade) - wallet Wallet @relation(fields: [walletId], references: [id], onDelete: Cascade) - withdrawl Withdrawl? @relation(fields: [withdrawlId], references: [id], onDelete: SetNull) + invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade) + protocol WalletProtocol @relation(fields: [protocolId], references: [id], onDelete: Cascade) + withdrawl Withdrawl? @relation(fields: [withdrawlId], references: [id], onDelete: SetNull) @@index([invoiceId]) - @@index([walletId]) + @@index([protocolId]) @@index([withdrawlId]) } @@ -1066,16 +951,16 @@ model Withdrawl { msatsFeePaid BigInt? status WithdrawlStatus? autoWithdraw Boolean @default(false) - walletId Int? + protocolId Int? user User @relation(fields: [userId], references: [id], onDelete: Cascade) - wallet Wallet? @relation(fields: [walletId], references: [id], onDelete: SetNull) + protocol WalletProtocol? @relation(fields: [protocolId], references: [id], onDelete: SetNull) invoiceForward InvoiceForward? WalletLog WalletLog[] @@index([createdAt], map: "Withdrawl.created_at_index") @@index([userId], map: "Withdrawl.userId_index") @@index([hash]) - @@index([walletId]) + @@index([protocolId]) @@index([autoWithdraw]) @@index([status]) } @@ -1292,9 +1177,283 @@ enum WithdrawlStatus { } enum LogLevel { + OK DEBUG INFO - WARN + WARNING ERROR - SUCCESS +} + +// =================== +// ==== WALLET V2 ==== +// =================== + +enum WalletProtocolName { + NWC + LNBITS + PHOENIXD + BLINK + WEBLN + LN_ADDR + LNC + CLN_REST + LND_GRPC +} + +enum WalletSendProtocolName { + NWC + LNBITS + PHOENIXD + BLINK + WEBLN + LNC +} + +enum WalletRecvProtocolName { + NWC + LNBITS + PHOENIXD + BLINK + LN_ADDR + CLN_REST + LND_GRPC +} + +enum WalletProtocolStatus { + OK + WARNING + ERROR +} + +enum WalletName { + ALBY + BLINK + BLIXT + CASHU_ME + CLN + COINOS + FOUNTAIN + LIFPAY + LNBITS + LND + MINIBITS + NPUB_CASH + PHOENIXD + PRIMAL + RIZFUL + SHOCKWALLET + SPEED + STRIKE + VOLTAGE + WALLET_OF_SATOSHI + ZBD + ZEUS + NWC + LN_ADDR + CASH_APP +} + +model WalletTemplate { + name WalletName @id + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + sendProtocols WalletSendProtocolName[] + recvProtocols WalletRecvProtocolName[] + wallets Wallet[] +} + +model Wallet { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + priority Int @default(0) + userId Int + templateName WalletName + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + template WalletTemplate @relation(fields: [templateName], references: [name], onDelete: Cascade) + + protocols WalletProtocol[] + + @@index([userId]) + @@index([templateName]) +} + +model WalletProtocol { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + + // NOTE: this denormalized json field exists to make polymorphic joins efficient + // when reading wallets ... it's populated by a trigger when wallet descendants update + // otherwise reading wallets would require a join on every descendant table. + // this pattern gives us fast reads and fast, type safe writes + config Json? @db.JsonB + + walletId Int + wallet Wallet @relation(fields: [walletId], references: [id], onDelete: Cascade) + send Boolean + name WalletProtocolName + enabled Boolean @default(true) + status WalletProtocolStatus @default(OK) + + withdrawals Withdrawl[] + directPayments DirectPayment[] + invoiceForward InvoiceForward[] + logs WalletLog[] + + walletSendNWC WalletSendNWC? + walletSendLNbits WalletSendLNbits? + walletSendPhoenixd WalletSendPhoenixd? + walletSendBlink WalletSendBlink? + walletSendWebLN WalletSendWebLN? + walletSendLNC WalletSendLNC? + + walletRecvNWC WalletRecvNWC? + walletRecvLNbits WalletRecvLNbits? + walletRecvPhoenixd WalletRecvPhoenixd? + walletRecvBlink WalletRecvBlink? + walletRecvLightningAddress WalletRecvLightningAddress? + walletRecvCLNRest WalletRecvCLNRest? + walletRecvLNDGRPC WalletRecvLNDGRPC? + + @@index([walletId]) + @@unique(name: "WalletProtocol_walletId_send_name_key", [walletId, send, name]) +} + +model WalletSendNWC { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + protocolId Int @unique + protocol WalletProtocol @relation(fields: [protocolId], references: [id], onDelete: Cascade) + urlVaultId Int @unique + url Vault @relation(fields: [urlVaultId], references: [id], onDelete: Cascade) +} + +model WalletSendLNbits { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + protocolId Int @unique + protocol WalletProtocol @relation(fields: [protocolId], references: [id], onDelete: Cascade) + url String + apiKeyVaultId Int @unique + apiKey Vault @relation(fields: [apiKeyVaultId], references: [id], onDelete: Cascade) +} + +model WalletSendPhoenixd { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + protocolId Int @unique + protocol WalletProtocol @relation(fields: [protocolId], references: [id], onDelete: Cascade) + url String + apiKeyVaultId Int @unique + apiKey Vault @relation(fields: [apiKeyVaultId], references: [id], onDelete: Cascade) +} + +model WalletSendBlink { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + protocolId Int @unique + protocol WalletProtocol @relation(fields: [protocolId], references: [id], onDelete: Cascade) + currencyVaultId Int @unique + currency Vault @relation("blinkCurrencySend", fields: [currencyVaultId], references: [id], onDelete: Cascade) + apiKeyVaultId Int @unique + apiKey Vault @relation("blinkApiKeySend", fields: [apiKeyVaultId], references: [id], onDelete: Cascade) +} + +model WalletSendWebLN { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + protocolId Int @unique + protocol WalletProtocol @relation(fields: [protocolId], references: [id], onDelete: Cascade) +} + +model WalletSendLNC { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + protocolId Int @unique + protocol WalletProtocol @relation(fields: [protocolId], references: [id], onDelete: Cascade) + pairingPhraseVaultId Int @unique + pairingPhrase Vault? @relation("lncPairingPhrase", fields: [pairingPhraseVaultId], references: [id]) + localKeyVaultId Int @unique + localKey Vault? @relation("lncLocalKey", fields: [localKeyVaultId], references: [id]) + remoteKeyVaultId Int @unique + remoteKey Vault? @relation("lncRemoteKey", fields: [remoteKeyVaultId], references: [id]) + serverHostVaultId Int @unique + serverHost Vault? @relation("lncServerHost", fields: [serverHostVaultId], references: [id]) +} + +model WalletRecvNWC { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + protocolId Int @unique + protocol WalletProtocol @relation(fields: [protocolId], references: [id], onDelete: Cascade) + url String +} + +model WalletRecvLNbits { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + protocolId Int @unique + protocol WalletProtocol @relation(fields: [protocolId], references: [id], onDelete: Cascade) + url String + apiKey String +} + +model WalletRecvPhoenixd { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + protocolId Int @unique + protocol WalletProtocol @relation(fields: [protocolId], references: [id], onDelete: Cascade) + url String + apiKey String +} + +model WalletRecvBlink { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + protocolId Int @unique + protocol WalletProtocol @relation(fields: [protocolId], references: [id], onDelete: Cascade) + currency String + apiKey String +} + +model WalletRecvLightningAddress { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + protocolId Int @unique + protocol WalletProtocol @relation(fields: [protocolId], references: [id], onDelete: Cascade) + address String +} + +model WalletRecvCLNRest { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + protocolId Int @unique + protocol WalletProtocol @relation(fields: [protocolId], references: [id], onDelete: Cascade) + socket String + rune String + cert String? +} + +model WalletRecvLNDGRPC { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + protocolId Int @unique + protocol WalletProtocol @relation(fields: [protocolId], references: [id], onDelete: Cascade) + socket String + macaroon String + cert String? } diff --git a/public/wallets/alby-dark.svg b/public/wallets/alby-dark.svg new file mode 100644 index 00000000..b9e0a5b4 --- /dev/null +++ b/public/wallets/alby-dark.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/wallets/alby.svg b/public/wallets/alby.svg new file mode 100644 index 00000000..073e825f --- /dev/null +++ b/public/wallets/alby.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/wallets/blixt-dark.svg b/public/wallets/blixt-dark.svg new file mode 100644 index 00000000..d2142252 --- /dev/null +++ b/public/wallets/blixt-dark.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/public/wallets/blixt.svg b/public/wallets/blixt.svg new file mode 100644 index 00000000..d1b84601 --- /dev/null +++ b/public/wallets/blixt.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/public/wallets/cashapp-dark.webp b/public/wallets/cashapp-dark.webp new file mode 100644 index 00000000..5aff8a3f Binary files /dev/null and b/public/wallets/cashapp-dark.webp differ diff --git a/public/wallets/cashapp.webp b/public/wallets/cashapp.webp new file mode 100644 index 00000000..5aff8a3f Binary files /dev/null and b/public/wallets/cashapp.webp differ diff --git a/public/wallets/cashu.me-dark.png b/public/wallets/cashu.me-dark.png new file mode 100644 index 00000000..05c7b386 Binary files /dev/null and b/public/wallets/cashu.me-dark.png differ diff --git a/public/wallets/cashu.me.png b/public/wallets/cashu.me.png new file mode 100644 index 00000000..707ccb10 Binary files /dev/null and b/public/wallets/cashu.me.png differ diff --git a/public/wallets/coinos-dark.svg b/public/wallets/coinos-dark.svg new file mode 100644 index 00000000..e262e11a --- /dev/null +++ b/public/wallets/coinos-dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/wallets/coinos.svg b/public/wallets/coinos.svg new file mode 100644 index 00000000..eb7a582f --- /dev/null +++ b/public/wallets/coinos.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/wallets/fountain-dark.png b/public/wallets/fountain-dark.png new file mode 100644 index 00000000..59a2e4fc Binary files /dev/null and b/public/wallets/fountain-dark.png differ diff --git a/public/wallets/fountain.png b/public/wallets/fountain.png new file mode 100644 index 00000000..28f0c435 Binary files /dev/null and b/public/wallets/fountain.png differ diff --git a/public/wallets/lifpay-dark.jpg b/public/wallets/lifpay-dark.jpg new file mode 100644 index 00000000..371486fd Binary files /dev/null and b/public/wallets/lifpay-dark.jpg differ diff --git a/public/wallets/lifpay-dark.png b/public/wallets/lifpay-dark.png new file mode 100644 index 00000000..a4a0faaa Binary files /dev/null and b/public/wallets/lifpay-dark.png differ diff --git a/public/wallets/lifpay.png b/public/wallets/lifpay.png new file mode 100644 index 00000000..ef2cd306 Binary files /dev/null and b/public/wallets/lifpay.png differ diff --git a/public/wallets/lnaddr-dark.png b/public/wallets/lnaddr-dark.png new file mode 100644 index 00000000..fe90be39 Binary files /dev/null and b/public/wallets/lnaddr-dark.png differ diff --git a/public/wallets/lnaddr.png b/public/wallets/lnaddr.png new file mode 100644 index 00000000..1b67c92f Binary files /dev/null and b/public/wallets/lnaddr.png differ diff --git a/public/wallets/minibits-dark.png b/public/wallets/minibits-dark.png new file mode 100644 index 00000000..4a9dd0b5 Binary files /dev/null and b/public/wallets/minibits-dark.png differ diff --git a/public/wallets/minibits.png b/public/wallets/minibits.png new file mode 100644 index 00000000..480de142 Binary files /dev/null and b/public/wallets/minibits.png differ diff --git a/public/wallets/npub-cash-dark.svg b/public/wallets/npub-cash-dark.svg new file mode 100644 index 00000000..e602f472 --- /dev/null +++ b/public/wallets/npub-cash-dark.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/wallets/npub-cash.svg b/public/wallets/npub-cash.svg new file mode 100644 index 00000000..d1d5defa --- /dev/null +++ b/public/wallets/npub-cash.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/wallets/nwc-dark.png b/public/wallets/nwc-dark.png new file mode 100644 index 00000000..79c52468 Binary files /dev/null and b/public/wallets/nwc-dark.png differ diff --git a/public/wallets/nwc.png b/public/wallets/nwc.png new file mode 100644 index 00000000..e44a3ce1 Binary files /dev/null and b/public/wallets/nwc.png differ diff --git a/public/wallets/primal-dark.svg b/public/wallets/primal-dark.svg new file mode 100644 index 00000000..1cceae1f --- /dev/null +++ b/public/wallets/primal-dark.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/wallets/primal.svg b/public/wallets/primal.svg new file mode 100644 index 00000000..0d834047 --- /dev/null +++ b/public/wallets/primal.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/wallets/rizful-dark.png b/public/wallets/rizful-dark.png new file mode 100644 index 00000000..852aee0e Binary files /dev/null and b/public/wallets/rizful-dark.png differ diff --git a/public/wallets/rizful.png b/public/wallets/rizful.png new file mode 100644 index 00000000..030668b1 Binary files /dev/null and b/public/wallets/rizful.png differ diff --git a/public/wallets/shockwallet-dark.png b/public/wallets/shockwallet-dark.png new file mode 100644 index 00000000..494bef89 Binary files /dev/null and b/public/wallets/shockwallet-dark.png differ diff --git a/public/wallets/shockwallet.png b/public/wallets/shockwallet.png new file mode 100644 index 00000000..772e17a3 Binary files /dev/null and b/public/wallets/shockwallet.png differ diff --git a/public/wallets/speed-dark.svg b/public/wallets/speed-dark.svg new file mode 100644 index 00000000..fb3dca86 --- /dev/null +++ b/public/wallets/speed-dark.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/wallets/speed.svg b/public/wallets/speed.svg new file mode 100644 index 00000000..fb3dca86 --- /dev/null +++ b/public/wallets/speed.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/wallets/strike-dark.png b/public/wallets/strike-dark.png new file mode 100644 index 00000000..66de91d9 Binary files /dev/null and b/public/wallets/strike-dark.png differ diff --git a/public/wallets/strike.png b/public/wallets/strike.png new file mode 100644 index 00000000..17476ae4 Binary files /dev/null and b/public/wallets/strike.png differ diff --git a/public/wallets/voltage-dark.svg b/public/wallets/voltage-dark.svg new file mode 100644 index 00000000..aa300a9e --- /dev/null +++ b/public/wallets/voltage-dark.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/public/wallets/voltage.svg b/public/wallets/voltage.svg new file mode 100644 index 00000000..76ab40e6 --- /dev/null +++ b/public/wallets/voltage.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/public/wallets/wos-dark.svg b/public/wallets/wos-dark.svg new file mode 100644 index 00000000..a27b2910 --- /dev/null +++ b/public/wallets/wos-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/wallets/wos.svg b/public/wallets/wos.svg new file mode 100644 index 00000000..a27b2910 --- /dev/null +++ b/public/wallets/wos.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/wallets/zbd-dark.png b/public/wallets/zbd-dark.png new file mode 100644 index 00000000..a627576d Binary files /dev/null and b/public/wallets/zbd-dark.png differ diff --git a/public/wallets/zbd.png b/public/wallets/zbd.png new file mode 100644 index 00000000..b8e04bdd Binary files /dev/null and b/public/wallets/zbd.png differ diff --git a/public/wallets/zbd.svg b/public/wallets/zbd.svg new file mode 100644 index 00000000..1ce092e3 --- /dev/null +++ b/public/wallets/zbd.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/wallets/zeus-dark.svg b/public/wallets/zeus-dark.svg new file mode 100644 index 00000000..5953e329 --- /dev/null +++ b/public/wallets/zeus-dark.svg @@ -0,0 +1,14 @@ + + + + + + + + + \ No newline at end of file diff --git a/public/wallets/zeus.svg b/public/wallets/zeus.svg new file mode 100644 index 00000000..5953e329 --- /dev/null +++ b/public/wallets/zeus.svg @@ -0,0 +1,14 @@ + + + + + + + + + \ No newline at end of file diff --git a/styles/dnd.module.css b/styles/dnd.module.css new file mode 100644 index 00000000..3b51ef8d --- /dev/null +++ b/styles/dnd.module.css @@ -0,0 +1,31 @@ +.draggable { + cursor: grab; + transition: all 0.2s ease-out; + position: relative; +} + +.dragging { + cursor: grabbing; + opacity: 0.3; + z-index: 1000; +} + +.dragOver { + transform: scale(1.03); + box-shadow: 0 0 10px var(--bs-info); +} + +@media (max-width: 768px) { + .draggable { + /* https://developer.mozilla.org/en-US/docs/Web/CSS/touch-action */ + touch-action: none; + user-select: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + } + + .dragging { + touch-action: none; + } +} \ No newline at end of file diff --git a/styles/log.module.css b/styles/logger.module.css similarity index 52% rename from styles/log.module.css rename to styles/logger.module.css index 12c15b90..6e7cf05c 100644 --- a/styles/log.module.css +++ b/styles/logger.module.css @@ -1,4 +1,4 @@ -.tableContainer { +.container { width: 100%; max-height: 60svh; overflow-y: auto; @@ -9,34 +9,56 @@ } @media screen and (min-width: 768px) { - .tableContainer { + .container { max-height: 70svh; } .embedded { - max-height: 30svh; + max-height: 25svh; } } -.tableRow { +.row { + display: flex; + gap: 0.5rem; font-family: monospace; color: var(--theme-grey) !important; /* .text-muted */ } -.timestamp { - vertical-align: top; - text-wrap: nowrap; - justify-self: first baseline; +.row:hover { + background-color: rgba(128, 128, 128, 0.1); } -.wallet { - vertical-align: top; - font-weight: bold; +.timestamp { + text-wrap: nowrap; + min-width: 20px; + text-align: right; } .level { font-weight: bold; - vertical-align: top; text-transform: uppercase; - padding-right: 0.5em; + min-width: 32px; +} + +.tag { + vertical-align: top; + font-weight: bold; +} + +.message { + word-break: break-word; +} + +.indicator { + margin-left: auto; +} + +.context { + flex-basis: 100%; + display: grid; + grid-template-columns: min-content auto; + column-gap: 0.5rem; + padding-left: 0.5rem; + padding-bottom: 0.5rem; } diff --git a/styles/wallet.module.css b/styles/wallet.module.css index 8c2501f3..f7912c17 100644 --- a/styles/wallet.module.css +++ b/styles/wallet.module.css @@ -16,91 +16,33 @@ } } -@media (max-width: 330px) { - .walletGrid { - grid-template-columns: 100%; - } - .walletGrid > * { - justify-self: center; - } -} - -.walletFilters { - grid-column: 1 / -1; - margin-left: 0.2rem; - user-select: none; -} - -.drag { - opacity: 33%; -} - -.drop { - box-shadow: 0 0 10px var(--bs-info); -} - .card { width: 160px; max-width: 100%; aspect-ratio: 160 / 180; + transition: transform 0.2s ease, opacity 0.2s ease, box-shadow 0.2s ease; } .indicators { display: flex; align-items: center; column-gap: 0.2rem; - margin-left: auto; padding: 10px; + justify-content: flex-end; position: absolute; + width: 100%; top: 0; right: 0; } -.walletLogo { - max-width: 100%; - max-height: 40%; - margin: auto; - text-align: center; - font-size: 1.5rem; - line-height: 1; -} - -.walletBanner { - max-width: min(256px, 100vw); - max-height: 100px; - padding: 0 15px 1rem 15px; -} - -.badge { - color: var(--theme-grey) !important; - background: var(--theme-clickToContextColor) !important; - vertical-align: middle; - margin-bottom: 0.1rem; - margin-top: 0.1rem; - margin-right: 0.2rem; -} - -.receive { - color: #20c997 !important; -} - -.send { - color: var(--bs-primary) !important; -} - -.attach { - color: var(--bs-body-color) !important; - text-align: center; -} - -.attach svg { - fill: var(--bs-body-color) !important; - margin-left: 0.5rem; -} - .indicator { width: 14px; height: 14px; + background-color: var(--theme-toolbarHover) !important; +} + +.indicator.drag { + background-color: transparent !important; } .indicator.success { @@ -127,6 +69,53 @@ border: 1px solid var(--theme-toolbarActive); } +.walletLogo { + max-width: 100%; + max-height: 40%; + margin: auto; + font-size: 1rem; + line-height: 1; +} + +.walletBanner { + max-width: min(256px, 100vw); + max-height: 100px; + padding: 0 15px 1rem 15px; +} + +.attach { + color: var(--bs-body-color) !important; + text-align: center; +} + +.attach svg { + fill: var(--bs-body-color) !important; + margin-left: 0.5rem; +} + +.nav { + justify-content: center; + font-size: 110%; + gap: 0 0.5rem; +} + +.nav :global .active { + border-bottom: 2px solid var(--bs-primary); +} + +.form { + display: flex; + justify-content: center; + max-width: 740px; + width: 100%; + padding-right: 15px; + padding-left: 15px; + margin-top: 5rem; + margin-left: auto; + margin-right: auto; + flex-direction: column; +} + .separator { display: flex; align-items: center; @@ -150,3 +139,10 @@ .separator:not(:empty)::after { margin-left: .25em; } + +.passphrase { + display: grid; + gap: 0.5rem; + grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); + container-type: inline-size; +} diff --git a/svgs/lock-line.svg b/svgs/lock-line.svg new file mode 100644 index 00000000..c8f7d931 --- /dev/null +++ b/svgs/lock-line.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/wallets/README.md b/wallets/README.md index 92a13f08..18809820 100644 --- a/wallets/README.md +++ b/wallets/README.md @@ -1,245 +1,369 @@ # Wallets -Every wallet that you can see at [/wallets](https://stacker.news/wallets) is implemented as a plugin in this directory. - -This README explains how you can add another wallet for use with Stacker News. - -> [!NOTE] -> Plugin means here that you only have to implement a common interface in this directory to add a wallet. - -## Plugin interface - -Every wallet is defined inside its own directory. Every directory must contain an _index.js_ and a _client.js_ file. - -An index.js file exports properties that can be shared by the client and server. - -Wallets that have spending permissions / can pay invoices export the payment interface in client.js. These permissions are stored on the client.[^1] - -[^1]: unencrypted in local storage until we have implemented encrypted local storage. - -A _server.js_ file is only required for wallets that support receiving by exposing the corresponding interface in that file. These wallets are stored on the server because payments are coordinated on the server so the server needs to generate these invoices for receiving. Additionally, permissions to receive a payment are not as sensitive as permissions to send a payment (highly sensitive!). - -> [!NOTE] -> Every wallet must have a client.js file (even if it does not support paying invoices) because every wallet is imported on the client. This is not the case on the server. On the client, wallets are imported via -> -> ```js -> import wallet from '@/wallets//client' -> ``` -> -> vs -> -> ```js -> import wallet from '@/wallets//server' -> ``` -> -> on the server. -> -> To have access to the properties that can be shared between client and server, server.js and client.js always reexport everything in index.js with a line like this: -> -> ```js -> export * from '@/wallets/' -> ``` -> -> If a wallet does not support paying invoices, this is all that client.js of this wallet does. The reason for this structure is to make sure the client does not import dependencies that can only be imported on the server and would thus break the build. - -> [!TIP] -> Don't hesitate to use the implementation of existing wallets as a reference. - -### index.js - -An index.js file exports the following properties that are shared by imports of this wallet on the server and wallet: - -- `name: string` - -This acts as an ID for this wallet on the client. It therefore must be unique across all wallets and is used throughout the code to reference this wallet. This name is also shown in the [wallet logs](https://stacker.news/wallet/logs). - -- `shortName?: string` - -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 [/wallets/lnbits](https://stacker.news/walletslnbits). - -- `card: WalletCard` - -Wallet cards are the components you can see at [/wallets](https://stacker.news/wallets). This property customizes this card for this wallet. - -- `validate: (config) => void` - -This is an optional function that's passed the final config after it has been validated. Validation is otherwise done on each individual field in `fields. This function can be used to implement additional validation logic. If the validation fails, the function should throw an error with a descriptive message for the user. - -This validation is triggered on save. - -- `walletType?: string` - -This field is only required if this wallet supports receiving payments. It must match a value of the enum `WalletType` in the database. - -- `walletField?: string` - -Just like `walletType`, this field is only required if this wallet supports receiving payments. It must match a column in the `Wallet` table. - -> [!NOTE] -> This is the only exception where you have to write code outside this directory for a wallet that supports receiving: you need to write a database migration to add a new enum value to `WalletType` and column to `Wallet`. See the top-level [README](../README.md#database-migrations) for how to do this. - -#### WalletField - -A wallet field is an object with the following properties: - -- `name: string` - -The configuration key. This is used by [Formik](https://formik.org/docs/overview) to map values to the correct input. This key is also what is used to save values in local storage or the database. For wallets that are stored on the server, this must therefore match a column in the corresponding table for wallets of this type. - -- `label: string` - -The label of the configuration key. Will be shown to the user in the form. - -- `type: 'text' | 'password'` - -The input type that should be used for this value. For example, if the type is `password`, the input value will be hidden by default using a component for passwords. - -- `validate: Yup.Schema | ((value) => void) | RegExp` - -This property defines how the value for this field should be validated. If a [Yup schema](https://github.com/jquense/yup?tab=readme-ov-file#object) is set, it will be used. Otherwise, the value will be validated by the function or the RegExp. When using a function, it is expected to throw an error with a descriptive message if the value is invalid. - -The validate field is required. - -- `optional?: boolean | string = false` - -This property can be used to mark a wallet field as optional. If it is not set, we will assume this field is required else 'optional' will be shown to the user next to the label. You can use Markdown to customize this text. - -- `help?: string | { label: string, text: string }` - -If this property is set, a help icon will be shown to the user. On click, the specified text in Markdown is shown. If you additionally want to customize the icon label, you can use the object syntax. - -- `editable?: boolean = true` - -If this property is set to `false`, you can only configure this value once. Afterwards, it's read-only. To configure it again, you have to detach the wallet first. - -- `placeholder?: string = ''` - -Placeholder text to show an example value to the user before they click into the input. - -- `hint?: string = ''` - -If a hint is set, it will be shown below the input. - -- `clear?: boolean = false` - -If a button to clear the input after it has been set should be shown, set this property to `true`. - -- `autoComplete?: HTMLAttribute<'autocomplete'>` - -This property controls the HTML `autocomplete` attribute. See [the documentation](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete) for possible values. Not setting it usually means that the user agent can use autocompletion. This property has no effect for passwords. Autocompletion is always turned off for passwords to prevent passwords getting saved for security reasons. - -- `clientOnly?: boolean = false` - -If this property is set to `true`, this field is only available on the client. If the stacker has device sync enabled, this field will be encrypted before being synced across devices. Otherwise, the field will be stored only on the current device. - -- `serverOnly?: boolean = false` - -If this property is set to `true`, this field is only meant to be used on the server and is safe to sync across devices in plain text. - -If neither `clientOnly` nor `serverOnly` is set, the field is assumed to be used on both the client and the server and safe to sync across devices in plain text. - -#### WalletCard - -- `title: string` - -The card title. - -- `subtitle: string` - -The subtitle that is shown below the title if you enter the configuration form of a wallet. - -- `image: { src: string, ... }` - -The image props that will be used to show an image inside the card. Should contain at least the `src` property. - -### client.js - -A wallet that supports paying invoices must export the following properties in client.js which are only available if this wallet is imported on the client: - -- `testSendPayment: async (config, context) => Promise` - -`testSendPayment` will be called during submit on the client to validate the configuration (that is passed as the first argument) more thoroughly than the initial validation by `fieldValidation`. It contains validation code that should only be called during submits instead of possibly on every change like `fieldValidation`. - -How this validation is implemented depends heavily on the wallet. For example, for NWC, this function attempts to fetch the info event from the relay specified in the connection string whereas for LNbits, it makes an HTTP request to /api/v1/wallet using the given URL and API key. - -This function must throw an error if the configuration was found to be invalid. - -The `context` argument is an object. It makes the wallet logger for this wallet as returned by `useWalletLogger` available under `context.logger`. See [wallets/logger.js](../wallets/logger.js). - -- `sendPayment: async (bolt11: string, config, context) => Promise` - -`sendPayment` will be called if a payment is required. Therefore, this function should implement the code to pay invoices from this wallet. - -The first argument is the [BOLT11 payment request](https://github.com/lightning/bolts/blob/master/11-payment-encoding.md). The `config` argument is the current configuration of this wallet (that was validated before). The `context` argument is the same as for `testSendPayment`. The function should return the preimage on payment success. - -> [!IMPORTANT] -> As mentioned above, this file must exist for every wallet and at least reexport everything in index.js so make sure that the following line is included: -> -> ```js -> // wallets//client.js -> export * from '@/wallets/' -> ``` -> -> where `` is the wallet directory name. - -> [!IMPORTANT] -> After you're done implementing the interface, you need to import this wallet in _wallets/client.js_ and add it to the array that is the default export of that file to make this wallet available across the code: -> -> ```diff -> // wallets/client.js -> import * as nwc from '@/wallets/nwc/client' -> import * as lnbits from '@/wallets/lnbits/client' -> import * as lnc from '@/wallets/lnc/client' -> import * as lnAddr from '@/wallets/lightning-address/client' -> import * as cln from '@/wallets/cln/client' -> import * as lnd from '@/wallets/lnd/client' -> + import * as newWallet from '@/wallets//client' -> -> - export default [nwc, lnbits, lnc, lnAddr, cln, lnd] -> + export default [nwc, lnbits, lnc, lnAddr, cln, lnd, newWallet] -> ``` - -### server.js - -A wallet that supports receiving must export the following properties in server.js which are only available if this wallet is imported on the server: - -- `testCreateInvoice: async (config, context) => Promise` - -`testCreateInvoice` is called on the server during submit and can thus use server dependencies like [`ln-service`](https://github.com/alexbosworth/ln-service). - -It should attempt to create a test invoice to make sure that this wallet can later create invoices for receiving. - -Again, like `testSendPayment`, the first argument is the wallet configuration that we should validate and this should thrown an error if validation fails. However, unlike `testSendPayment`, the `context` argument here contains `me` (the user object) and `models` (the Prisma client). - -- `createInvoice: async (invoiceParams, config, context) => Promise` - -`createInvoice` will be called whenever this wallet should receive a payment. It should return a BOLT11 payment request. The first argument `invoiceParams` is an object that contains the invoice parameters. These include `msats`, `description`, `descriptionHash` and `expiry`. The second argument `config` is the current configuration of this wallet. The third argument `context` is the same as in `testCreateInvoice` except it also includes `lnd` which is the return value of [`authenticatedLndGrpc`](https://github.com/alexbosworth/ln-service?tab=readme-ov-file#authenticatedlndgrpc) using the SN node credentials. - - -> [!IMPORTANT] -> Don't forget to include the following line: -> -> ```js -> // wallets//server.js -> export * from '@/wallets/' -> ``` -> -> where `` is the wallet directory name. - -> [!IMPORTANT] -> After you're done implementing the interface, you need to import this wallet in _wallets/server.js_ and add it to the array that is the default export of that file to make this wallet available across the code: -> -> ```diff -> // wallets/server.js -> import * as lnd from '@/wallets/lnd/server' -> import * as cln from '@/wallets/cln/server' -> import * as lnAddr from '@/wallets/lightning-address/server' -> + import * as newWallet from '@/wallets//client' -> -> - export default [lnd, cln, lnAddr] -> + export default [lnd, cln, lnAddr, newWallet] -> ``` \ No newline at end of file +## How to add a new wallet + +**1. Insert a new row to the `WalletTemplate` table with which protocols it supports** + +Example: + +```sql +INSERT INTO "WalletTemplate" (name, "sendProtocols", "recvProtocols") +VALUES ( + 'PHOENIX', + ARRAY[]::"WalletSendProtocolName"[], + ARRAY['BOLT12']::"WalletRecvProtocolName"[] +); +``` + +**2. Customize how the wallet looks on the client via [wallets/lib/wallets.json](/wallets/lib/wallets.json)** + +Example: + +```json +{ + // must be same name as wallet template + "name": "PHOENIX", + // name to show in client + "displayName": "Phoenix", + // image to show in client + "image": "/path/to/image.png", + // url (planned) to show in client + "url": "https://phoenix.acinq.co/" +} +``` + +_If the wallet supports a lightning address and the domain is different than the url, you can pass an object to `url`. Here is Zeus as an example:_ + +```json +{ + "templateId": 23, + "name": "ZEUS", + "displayName": "Zeus", + "image": "/wallets/zeus.svg", + "url": { + "wallet": "https://zeusln.com/", + // different domain for lightning address + "lud16Domain": "zeuspay.com" + } +}, +``` + +That's it! + +## How to add a new protocol + +**1. Update prisma.schema** + +- add enum value to `WalletProtocolName` enum +- add enum value to `WalletRecvProtocolName` or `WalletSendProtocolName` +- add table to store protocol config +- run `npx prisma migrate dev --create-only` + +
+Example + +```diff +diff --git a/prisma/schema.prisma b/prisma/schema.prisma +index 9a113797..12505333 100644 +--- a/prisma/schema.prisma ++++ b/prisma/schema.prisma +@@ -1199,6 +1199,7 @@ enum WalletProtocolName { + LNC + CLN_REST + LND_GRPC ++ BOLT12 + } + + enum WalletSendProtocolName { +@@ -1218,6 +1219,7 @@ enum WalletRecvProtocolName { + LN_ADDR + CLN_REST + LND_GRPC ++ BOLT12 + } + + enum WalletProtocolStatus { +@@ -1288,6 +1290,7 @@ model WalletProtocol { + walletRecvLightningAddress WalletRecvLightningAddress? + walletRecvCLNRest WalletRecvCLNRest? + walletRecvLNDGRPC WalletRecvLNDGRPC? ++ walletRecvBolt12 WalletRecvBolt12? + + @@unique(name: "WalletProtocol_walletId_send_name_key", [walletId, send, name]) + } +@@ -1429,3 +1432,12 @@ model WalletRecvLNDGRPC { + macaroon String + cert String? + } ++ ++model WalletRecvBolt12 { ++ id Int @id @default(autoincrement()) ++ createdAt DateTime @default(now()) @map("created_at") ++ updatedAt DateTime @default(now()) @updatedAt @map("updated_at") ++ protocolId Int @unique ++ protocol WalletProtocol @relation(fields: [protocolId], references: [id], onDelete: Cascade) ++ offer String ++} +``` + +
+ +
+ +**2. Update migration file** + +- add required triggers (`wallet_to_jsonb` and `wallet_clear_vault` if send protocol) to migration file +- run `npx prisma migrate dev` + +
+Example + +```sql +-- AlterEnum +ALTER TYPE "WalletProtocolName" ADD VALUE 'BOLT12'; + +-- AlterEnum +ALTER TYPE "WalletRecvProtocolName" ADD VALUE 'BOLT12'; + +-- CreateTable +CREATE TABLE "WalletRecvBolt12" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "protocolId" INTEGER NOT NULL, + "offer" TEXT NOT NULL, + + CONSTRAINT "WalletRecvBolt12_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletRecvBolt12_protocolId_key" ON "WalletRecvBolt12"("protocolId"); + +-- AddForeignKey +ALTER TABLE "WalletRecvBolt12" ADD CONSTRAINT "WalletRecvBolt12_protocolId_fkey" FOREIGN KEY ("protocolId") REFERENCES "WalletProtocol"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- vvv Add trigger below manually vvv + +CREATE TRIGGER wallet_to_jsonb + AFTER INSERT OR UPDATE ON "WalletRecvBolt12" + FOR EACH ROW + EXECUTE PROCEDURE wallet_to_jsonb(); + + +-- if protocol is for sending you also need to add the wallet_clear_vault trigger: +-- CREATE TRIGGER wallet_clear_vault +-- AFTER DELETE ON "WalletSendClinkDebit" +-- FOR EACH ROW +-- EXECUTE PROCEDURE wallet_clear_vault(); + +``` + +
+ +
+ +**3. Add protocol lib file** + +- add file to [wallets/lib/protocols](/wallets/lib/protocols) (see [JSDoc](/wallets/lib/protocols/index.js) for details) +- import in index.js file and add to default export + +
+Example + +```js +// wallets/lib/protocols/bolt12.js + +export default [ + { + // same as enum value we added + name: 'BOLT12', + displayName: 'BOLT12', + send: false, + fields: [ + { + name: 'offer', + type: 'text', + label: 'offer', + placeholder: 'lno...', + validate: offerValidator, + required: true, + } + ], + relationName: 'walletRecvBolt12' + } +] +``` + +```diff +diff --git a/wallets/lib/protocols/index.js b/wallets/lib/protocols/index.js +index 8caa5f52..58f5ab86 100644 +--- a/wallets/lib/protocols/index.js ++++ b/wallets/lib/protocols/index.js +@@ -7,6 +7,7 @@ import lnbitsSuite from './lnbits' + import phoenixdSuite from './phoenixd' + import blinkSuite from './blink' + import webln from './webln' ++import bolt12 from './bolt12' + + /** + * Protocol names as used in the database +@@ -44,5 +45,6 @@ export default [ + ...phoenixdSuite, + ...lnbitsSuite, + ...blinkSuite, +- webln ++ webln, ++ bolt12 + ] +``` + +
+ +
+ +**4. Add protocol method file** + +- if protocol to receive payments: Add file to [wallets/server/protocols](/wallets/server/protocols) (see [JSDoc](/wallets/server/protocols/index.js) for details) +- if protocol to send payments: Add file to [wallets/client/protocols](/wallets/client/protocols) (see [JSDoc](/wallets/client/protocols/index.js) for details) +- import in index.js file and add to default export + +
+Example + +```js +// wallets/server/protocols/bolt12.js + +// same as enum value we added +export const name = 'BOLT12' + +export async function createInvoice ({ msats, description, expiry }, config, { signal }) { + /* ... code to create invoice using protocol config ... */ +} + +export async function testCreateInvoice ({ url }, { signal }) { + return await createInvoice( + { msats: 1000, description: 'SN test invoice', expiry: 1 }, + { url }, + { signal } + ) +} +``` + +```diff +diff --git a/wallets/server/protocols/index.js b/wallets/server/protocols/index.js +index 26c292d9..3ac88ae1 100644 +--- a/wallets/server/protocols/index.js ++++ b/wallets/server/protocols/index.js +@@ -5,6 +5,7 @@ import * as clnRest from './clnRest' + import * as phoenixd from './phoenixd' + import * as blink from './blink' + import * as lndGrpc from './lndGrpc' ++import * as bolt12 from './bolt12' + + export * from './util' + +@@ -56,5 +57,6 @@ export default [ + clnRest, + phoenixd, + blink, +- lndGrpc ++ lndGrpc, ++ bolt12 + ] +``` + +
+ +
+ +**5. Update GraphQL code** + +- add GraphQL type +- add GraphQL type to `WalletProtocolConfig` union +- add GraphQL type to `WalletProtocolFields` fragment via spread operator (...) +- add GraphQL mutation to upsert protocol +- resolve GraphQL type in `mapWalletResolveTypes` function + +
+Example + +```diff +diff --git a/api/typeDefs/wallet.js b/api/typeDefs/wallet.js +index 3c1fffd1..af3858a5 100644 +--- a/api/typeDefs/wallet.js ++++ b/api/typeDefs/wallet.js +@@ -38,6 +38,7 @@ const typeDefs = gql` + upsertWalletRecvLNDGRPC(walletId: ID, templateId: ID, enabled: Boolean!, networkTests: Boolean, socket: String!, macaroon: String!, cert: String): WalletRecvLNDGRPC! + upsertWalletSendLNC(walletId: ID, templateId: ID, enabled: Boolean!, pairingPhrase: VaultEntryInput!, localKey: VaultEntryInput!, remoteKey: VaultEntryInput!, serverHost: VaultEntryInput!): WalletSendLNC! + upsertWalletSendWebLN(walletId: ID, templateId: ID, enabled: Boolean!): WalletSendWebLN! ++ upsertWalletRecvBolt12(walletId: ID, templateId: ID, enabled: Boolean!, networkTests: Boolean, offer: String!): WalletRecvBolt12! + removeWalletProtocol(id: ID!): Boolean + updateWalletEncryption(keyHash: String!, wallets: [WalletEncryptionUpdate!]!): Boolean + updateKeyHash(keyHash: String!): Boolean +@@ -111,6 +112,7 @@ const typeDefs = gql` + | WalletRecvLightningAddress + | WalletRecvCLNRest + | WalletRecvLNDGRPC ++ | WalletRecvBolt12 + + type WalletSettings { + receiveCreditsBelowSats: Int! +@@ -207,6 +209,11 @@ const typeDefs = gql` + cert: String + } + ++ type WalletRecvBolt12 { ++ id: ID! ++ offer: String! ++ } ++ + input AutowithdrawSettings { + autoWithdrawThreshold: Int! + autoWithdrawMaxFeePercent: Float! +diff --git a/wallets/client/fragments/protocol.js b/wallets/client/fragments/protocol.js +index d1a65ff4..138d1a62 100644 +--- a/wallets/client/fragments/protocol.js ++++ b/wallets/client/fragments/protocol.js +@@ -109,3 +109,11 @@ export const UPSERT_WALLET_SEND_WEBLN = gql` + } + } + ` ++ ++export const UPSERT_WALLET_RECEIVE_BOLT12 = gql` ++ mutation upsertWalletRecvBolt12($walletId: ID, $templateId: ID, $enabled: Boolean!, $networkTests: Boolean, $offer: String!) { ++ upsertWalletRecvBolt12(walletId: $walletId, templateId: $templateId, enabled: $enabled, networkTests: $networkTests, offer: $offer) { ++ id ++ } ++ } ++` +diff --git a/wallets/client/fragments/wallet.js b/wallets/client/fragments/wallet.js +index c301f5c1..73d59e6d 100644 +--- a/wallets/client/fragments/wallet.js ++++ b/wallets/client/fragments/wallet.js +@@ -106,6 +106,10 @@ const WALLET_PROTOCOL_FIELDS = gql` + macaroon + cert + } ++ ... on WalletRecvBolt12 { ++ id ++ offer ++ } + } + } + ` +diff --git a/wallets/server/resolvers/util.js b/wallets/server/resolvers/util.js +index 0155a422..ced4b399 100644 +--- a/wallets/server/resolvers/util.js ++++ b/wallets/server/resolvers/util.js +@@ -19,6 +19,8 @@ export function mapWalletResolveTypes (wallet) { + return 'WalletRecvCLNRest' + case 'LND_GRPC': + return 'WalletRecvLNDGRPC' ++ case 'BOLT12': ++ return 'WalletRecvBolt12' + default: + return null + } +``` + +
diff --git a/wallets/blink/common.js b/wallets/blink/common.js deleted file mode 100644 index 75540115..00000000 --- a/wallets/blink/common.js +++ /dev/null @@ -1,67 +0,0 @@ -import { fetchWithTimeout } from '@/lib/fetch' -import { assertContentTypeJson, assertResponseOk } from '@/lib/url' - -export const galoyBlinkUrl = 'https://api.blink.sv/graphql' -export const galoyBlinkDashboardUrl = 'https://dashboard.blink.sv/' - -export const SCOPE_READ = 'READ' -export const SCOPE_WRITE = 'WRITE' -export const SCOPE_RECEIVE = 'RECEIVE' - -export async function getWallet ({ apiKey, currency }, { signal }) { - const out = await request({ - apiKey, - query: ` - query me { - me { - defaultAccount { - wallets { - id - walletCurrency - } - } - } - }` - }, { signal }) - - const wallets = out.data.me.defaultAccount.wallets - for (const wallet of wallets) { - if (wallet.walletCurrency === currency) { - return wallet - } - } - - throw new Error(`wallet ${currency} not found`) -} - -export async function request ({ apiKey, query, variables = {} }, { signal }) { - const method = 'POST' - const res = await fetchWithTimeout(galoyBlinkUrl, { - method, - headers: { - 'Content-Type': 'application/json', - 'X-API-KEY': apiKey - }, - body: JSON.stringify({ query, variables }), - signal - }) - - assertResponseOk(res, { method }) - assertContentTypeJson(res, { method }) - - return res.json() -} - -export async function getScopes ({ apiKey }, { signal }) { - const out = await request({ - apiKey, - query: ` - query scopes { - authorization { - scopes - } - }` - }, { signal }) - const scopes = out?.data?.authorization?.scopes - return scopes || [] -} diff --git a/wallets/blink/index.js b/wallets/blink/index.js deleted file mode 100644 index d870592c..00000000 --- a/wallets/blink/index.js +++ /dev/null @@ -1,71 +0,0 @@ -import { string } from '@/lib/yup' -import { galoyBlinkDashboardUrl } from '@/wallets/blink/common' - -export const name = 'blink' -export const walletType = 'BLINK' -export const walletField = 'walletBlink' - -export const fields = [ - { - name: 'apiKey', - label: 'api key', - type: 'password', - placeholder: 'blink_...', - clientOnly: true, - validate: string() - .matches(/^blink_[A-Za-z0-9]+$/, { message: 'must match pattern blink_A-Za-z0-9' }), - help: `you can get an API key from [Blink Dashboard](${galoyBlinkDashboardUrl}).\nPlease make sure to select ONLY the 'Read' and 'Write' scopes when generating this API key.`, - requiredWithout: 'apiKeyRecv', - optional: 'for sending' - }, - { - name: 'currency', - label: 'wallet type', - type: 'text', - help: 'the blink wallet to use for sending (BTC or USD for stablesats)', - placeholder: 'BTC', - defaultValue: 'BTC', - clear: true, - autoComplete: 'off', - clientOnly: true, - validate: string() - .transform(value => value ? value.toUpperCase() : 'BTC') - .oneOf(['USD', 'BTC'], 'must be BTC or USD'), - optional: 'for sending', - requiredWithout: 'currencyRecv' - }, - { - name: 'apiKeyRecv', - label: 'receive api key', - type: 'password', - help: `you can get an API key from [Blink Dashboard](${galoyBlinkDashboardUrl}).\nPlease make sure to select ONLY the 'Read' and 'Receive' scopes when generating this API key.`, - placeholder: 'blink_...', - serverOnly: true, - validate: string() - .matches(/^blink_[A-Za-z0-9]+$/, { message: 'must match pattern blink_A-Za-z0-9' }), - optional: 'for receiving', - requiredWithout: 'apiKey' - }, - { - name: 'currencyRecv', - label: 'receive wallet type', - type: 'text', - help: 'the blink wallet to use for receiving (only BTC available)', - defaultValue: 'BTC', - clear: true, - autoComplete: 'off', - placeholder: 'BTC', - serverOnly: true, - validate: string() - .transform(value => value ? value.toUpperCase() : 'BTC') - .oneOf(['BTC'], 'must be BTC'), - optional: 'for receiving', - requiredWithout: 'currency' - } -] - -export const card = { - title: 'Blink', - subtitle: 'use [Blink](https://blink.sv/) for payments', - image: { src: '/wallets/blink.svg' } -} diff --git a/wallets/buttonbar.js b/wallets/buttonbar.js deleted file mode 100644 index 04b8c1b8..00000000 --- a/wallets/buttonbar.js +++ /dev/null @@ -1,24 +0,0 @@ -import { Button } from 'react-bootstrap' -import CancelButton from '@/components/cancel-button' -import { SubmitButton } from '@/components/form' -import { isConfigured } from '@/wallets/common' - -export default function WalletButtonBar ({ - wallet, disable, - className, children, onDelete, onCancel, hasCancel = true, - createText = 'attach', deleteText = 'detach', editText = 'save' -}) { - return ( -
-
- {isConfigured(wallet) && wallet.def.requiresConfig && - } - {children} -
- {hasCancel && } - {isConfigured(wallet) ? editText : createText} -
-
-
- ) -} diff --git a/wallets/card.js b/wallets/card.js deleted file mode 100644 index a066884a..00000000 --- a/wallets/card.js +++ /dev/null @@ -1,56 +0,0 @@ -import { Card } from 'react-bootstrap' -import styles from '@/styles/wallet.module.css' -import Plug from '@/svgs/plug.svg' -import Gear from '@/svgs/settings-5-fill.svg' -import Link from 'next/link' -import { isConfigured } from '@/wallets/common' -import DraggableIcon from '@/svgs/draggable.svg' -import RecvIcon from '@/svgs/arrow-left-down-line.svg' -import SendIcon from '@/svgs/arrow-right-up-line.svg' -import { useWalletImage } from '@/wallets/image' -import { useWalletStatus, statusToClass, Status } from '@/wallets/status' -import { useWalletSupport } from '@/wallets/support' - -export default function WalletCard ({ wallet, draggable, onDragStart, onDragEnter, onDragEnd, onTouchStart, sourceIndex, targetIndex, index }) { - const image = useWalletImage(wallet) - const status = useWalletStatus(wallet) - const support = useWalletSupport(wallet) - - return ( - -
- {status.any !== Status.Disabled && } - {support.recv && } - {support.send && } -
- -
- {image - ? - : {wallet.def.card.title}} -
-
- - - {isConfigured(wallet) - ? <>configure - : <>attach} - - -
- ) -} diff --git a/wallets/client.js b/wallets/client.js deleted file mode 100644 index 8bd44698..00000000 --- a/wallets/client.js +++ /dev/null @@ -1,11 +0,0 @@ -import * as nwc from '@/wallets/nwc/client' -import * as lnbits from '@/wallets/lnbits/client' -import * as lnc from '@/wallets/lnc/client' -import * as lnAddr from '@/wallets/lightning-address/client' -import * as cln from '@/wallets/cln/client' -import * as lnd from '@/wallets/lnd/client' -import * as webln from '@/wallets/webln/client' -import * as blink from '@/wallets/blink/client' -import * as phoenixd from '@/wallets/phoenixd/client' - -export default [nwc, lnbits, lnc, lnAddr, cln, lnd, webln, blink, phoenixd] diff --git a/wallets/client/components/card.js b/wallets/client/components/card.js new file mode 100644 index 00000000..26a061a6 --- /dev/null +++ b/wallets/client/components/card.js @@ -0,0 +1,71 @@ +import { Card } from 'react-bootstrap' +import classNames from 'classnames' +import styles from '@/styles/wallet.module.css' +import Plug from '@/svgs/plug.svg' +import Gear from '@/svgs/settings-5-fill.svg' +import Link from 'next/link' +import RecvIcon from '@/svgs/arrow-left-down-line.svg' +import SendIcon from '@/svgs/arrow-right-up-line.svg' +import DragIcon from '@/svgs/draggable.svg' +import { useWalletImage, useWalletSupport, useWalletStatus, WalletStatus } from '@/wallets/client/hooks' +import { isWallet, urlify, walletDisplayName } from '@/wallets/lib/util' +import { Draggable } from '@/wallets/client/components' + +export function WalletCard ({ wallet, draggable = false, index, ...props }) { + const image = useWalletImage(wallet.name) + const status = useWalletStatus(wallet) + const support = useWalletSupport(wallet) + + const card = ( + +
+ {draggable && } + {support.receive && } + {support.send && } +
+ +
+ {image + ? + : {walletDisplayName(wallet.name)}} +
+
+ + + {isWallet(wallet) + ? <>configure + : <>attach} + + +
+ ) + + if (draggable) { + return ( + + {card} + + ) + } + + return card +} + +function WalletLink ({ wallet, children }) { + const support = useWalletSupport(wallet) + const sendRecvParam = support.send ? 'send' : 'receive' + const href = '/wallets' + (isWallet(wallet) ? `/${wallet.id}` : `/${urlify(wallet.name)}`) + `/${sendRecvParam}` + return {children} +} + +function statusToClass (status) { + switch (status) { + case WalletStatus.OK: return styles.success + case WalletStatus.ERROR: return styles.error + case WalletStatus.WARNING: return styles.warning + case WalletStatus.DISABLED: return styles.disabled + } +} diff --git a/wallets/client/components/draggable.js b/wallets/client/components/draggable.js new file mode 100644 index 00000000..c53677e8 --- /dev/null +++ b/wallets/client/components/draggable.js @@ -0,0 +1,44 @@ +import { useDndHandlers } from '@/wallets/client/context' +import classNames from 'classnames' +import styles from '@/styles/dnd.module.css' + +export function Draggable ({ children, index }) { + const { + handleDragStart, + handleDragOver, + handleDragEnter, + handleDragLeave, + handleDrop, + handleDragEnd, + handleTouchStart, + handleTouchMove, + handleTouchEnd, + isBeingDragged, + isDragOver + } = useDndHandlers(index) + + return ( +
+ {children} +
+ ) +} diff --git a/wallets/client/components/forms.js b/wallets/client/components/forms.js new file mode 100644 index 00000000..fd634b34 --- /dev/null +++ b/wallets/client/components/forms.js @@ -0,0 +1,359 @@ +import { useEffect, useCallback, useMemo, createContext, useContext } from 'react' +import { Button, InputGroup, Nav } from 'react-bootstrap' +import Link from 'next/link' +import { useParams, usePathname } from 'next/navigation' +import { useRouter } from 'next/router' +import { WalletLayout, WalletLayoutHeader, WalletLayoutImageOrName, WalletLogs } from '@/wallets/client/components' +import { protocolDisplayName, protocolFields, protocolClientSchema, unurlify, urlify, isWallet, isTemplate, walletLud16Domain } from '@/wallets/lib/util' +import styles from '@/styles/wallet.module.css' +import { Checkbox, Form, Input, PasswordInput, SubmitButton } from '@/components/form' +import CancelButton from '@/components/cancel-button' +import { useWalletProtocolUpsert, useWalletProtocolRemove, useWalletQuery, TemplateLogsProvider } from '@/wallets/client/hooks' +import { useToast } from '@/components/toast' +import Text from '@/components/text' +import Info from '@/components/info' + +const WalletFormsContext = createContext() + +export function WalletForms ({ id, name }) { + // TODO(wallet-v2): handle loading and error states + const { data, refetch } = useWalletQuery({ name, id }) + const wallet = data?.wallet + + return ( + +
+ + {wallet && } + + {wallet && ( + + + + )} +
+
+ ) +} + +function WalletFormsProvider ({ children, wallet, refetch }) { + const value = useMemo(() => ({ refetch, wallet }), [refetch, wallet]) + return ( + + {children} + + ) +} + +function useWalletRefetch () { + const { refetch } = useContext(WalletFormsContext) + return refetch +} + +function useWallet () { + const { wallet } = useContext(WalletFormsContext) + return wallet +} + +function WalletFormSelector () { + const sendRecvParam = useSendRecvParam() + const protocolParam = useWalletProtocolParam() + + return ( + <> + + {sendRecvParam && ( +
+
+ + {protocolParam && ( + + + + )} +
+
+ )} + + ) +} + +function WalletSendRecvSelector () { + const path = useWalletPathname() + const selected = useSendRecvParam() + + // TODO(wallet-v2): if you click a nav link again, it will update the URL + // but not run the effect again to select the first protocol by default + return ( + + ) +} + +function WalletProtocolSelector () { + const walletPath = useWalletPathname() + const sendRecvParam = useSendRecvParam() + const path = `${walletPath}/${sendRecvParam}` + + const protocols = useWalletProtocols() + const selected = useWalletProtocolParam() + const router = useRouter() + + useEffect(() => { + if (!selected && protocols.length > 0) { + router.replace(`/${path}/${urlify(protocols[0].name)}`, null, { shallow: true }) + } + }, [path]) + + if (protocols.length === 0) { + // TODO(wallet-v2): let user know how to request support if the wallet actually does support sending + return ( +
+ {sendRecvParam === 'send' ? 'sending' : 'receiving'} not supported +
+ ) + } + + return ( + + ) +} + +function WalletProtocolForm () { + const sendRecvParam = useSendRecvParam() + const router = useRouter() + const protocol = useSelectedProtocol() + if (!protocol) return null + + // I think it is okay to skip this hook if the protocol is not found + // because we will need to change the URL to get a different protocol + // so the amount of rendered hooks should stay the same during the lifecycle of this component + const wallet = useWallet() + const upsertWalletProtocol = useWalletProtocolUpsert(wallet, protocol) + const toaster = useToast() + const refetch = useWalletRefetch() + + const { fields, initial, schema } = useProtocolForm(protocol) + + // create a copy of values to avoid mutating the original + const onSubmit = useCallback(async ({ ...values }) => { + const lud16Domain = walletLud16Domain(wallet.name) + if (values.address && lud16Domain) { + values.address = `${values.address}@${lud16Domain}` + } + + const upsert = await upsertWalletProtocol(values) + if (isWallet(wallet)) { + toaster.success('wallet saved') + refetch() + return + } + // we just created a new user wallet from a template + router.replace(`/wallets/${upsert.id}/${sendRecvParam}`, null, { shallow: true }) + toaster.success('wallet attached', { persistOnNavigate: true }) + }, [upsertWalletProtocol, toaster, wallet, router]) + + return ( + <> +
+ {fields.map(field => )} + + + + + + ) +} + +function WalletProtocolFormButtons () { + const protocol = useSelectedProtocol() + const removeWalletProtocol = useWalletProtocolRemove(protocol) + const refetch = useWalletRefetch() + const router = useRouter() + const wallet = useWallet() + const isLastProtocol = wallet.protocols.length === 1 + + const onDetach = useCallback(async () => { + await removeWalletProtocol() + if (isLastProtocol) { + router.replace('/wallets', null, { shallow: true }) + return + } + refetch() + }, [removeWalletProtocol, refetch, isLastProtocol, router]) + + return ( +
+ {!isTemplate(protocol) && } + cancel + {isWallet(wallet) ? 'save' : 'attach'} +
+ ) +} + +function WalletProtocolFormField ({ type, ...props }) { + const wallet = useWallet() + const protocol = useSelectedProtocol() + + function transform ({ validate, encrypt, editable, help, ...props }) { + const [upperHint, bottomHint] = Array.isArray(props.hint) ? props.hint : [null, props.hint] + + const parseHelpText = text => Array.isArray(text) ? text.join('\n\n') : text + const _help = help + ? ( + typeof help === 'string' + ? { label: null, text: help } + : ( + Array.isArray(help) + ? { label: null, text: parseHelpText(help) } + : { label: help.label, text: parseHelpText(help.text) } + ) + ) + : null + + const readOnly = !!protocol.config?.[props.name] && editable === false + + const label = ( +
+ {props.label} + {_help && ( + + {_help.text} + + )} + + {upperHint + ? {upperHint} + : (!props.required ? 'optional' : null)} + +
+ ) + + return { ...props, hint: bottomHint, label, readOnly } + } + + switch (type) { + case 'text': { + let append + const lud16Domain = walletLud16Domain(wallet.name) + if (props.name === 'address' && lud16Domain) { + append = @{lud16Domain} + } + return + } + case 'password': + return + default: + return null + } +} + +function useWalletPathname () { + const pathname = usePathname() + // returns /wallets/:name + return pathname.split('/').filter(Boolean).slice(0, 2).join('/') +} + +function useSendRecvParam () { + const params = useParams() + // returns only :send in /wallets/:name/:send + return ['send', 'receive'].includes(params.slug[1]) ? params.slug[1] : null +} + +function useWalletProtocolParam () { + const params = useParams() + const name = params.slug[2] + // returns only :protocol in /wallets/:name/:send/:protocol + return name ? unurlify(name) : null +} + +function useWalletProtocols () { + const wallet = useWallet() + const sendRecvParam = useSendRecvParam() + if (!sendRecvParam) return [] + + const protocolFilter = p => sendRecvParam === 'send' ? p.send : !p.send + return isWallet(wallet) + ? wallet.template.protocols.filter(protocolFilter) + : wallet.protocols.filter(protocolFilter) +} + +function useSelectedProtocol () { + const wallet = useWallet() + const sendRecvParam = useSendRecvParam() + const protocolParam = useWalletProtocolParam() + + const send = sendRecvParam === 'send' + let protocol = wallet.protocols.find(p => p.name === protocolParam && p.send === send) + if (!protocol && isWallet(wallet)) { + // the protocol was not found as configured, look for it in the template + protocol = wallet.template.protocols.find(p => p.name === protocolParam && p.send === send) + } + + return protocol +} + +function useProtocolForm (protocol) { + const wallet = useWallet() + const lud16Domain = walletLud16Domain(wallet.name) + const fields = protocolFields(protocol) + const initial = fields.reduce((acc, field) => { + // wallet templates don't have a config + let value = protocol.config?.[field.name] + + if (field.name === 'address' && lud16Domain && value) { + value = value.split('@')[0] + } + + return { + ...acc, + [field.name]: value || '' + } + }, { enabled: protocol.enabled }) + + let schema = protocolClientSchema(protocol) + if (lud16Domain) { + schema = schema.transform(({ address, ...rest }) => { + return { + address: address ? `${address}@${lud16Domain}` : '', + ...rest + } + }) + } + + return { fields, initial, schema } +} diff --git a/wallets/client/components/index.js b/wallets/client/components/index.js new file mode 100644 index 00000000..76755a07 --- /dev/null +++ b/wallets/client/components/index.js @@ -0,0 +1,6 @@ +export * from './card' +export * from './draggable' +export * from './forms' +export * from './layout' +export * from './passphrase' +export * from './logger' diff --git a/wallets/client/components/layout.js b/wallets/client/components/layout.js new file mode 100644 index 00000000..c13f1259 --- /dev/null +++ b/wallets/client/components/layout.js @@ -0,0 +1,58 @@ +import Layout from '@/components/layout' +import { useWalletImage } from '@/wallets/client/hooks' +import { walletDisplayName } from '@/wallets/lib/util' +import Link from 'next/link' + +export function WalletLayout ({ children }) { + // TODO(wallet-v2): py-5 doesn't work, I think it gets overriden by the layout class + // so I still need to add it manually to the first child ... + return ( + + {children} + + ) +} + +export function WalletLayoutHeader ({ children }) { + return ( +

+ {children} +

+ ) +} + +export function WalletLayoutSubHeader ({ children }) { + return ( +
+ {children} +
+ ) +} + +export function WalletLayoutLink ({ children, href }) { + return ( + + {children} + + ) +} + +export function WalletLayoutImageOrName ({ name, maxHeight = '50px' }) { + const img = useWalletImage(name) + return ( +
+ {img + ? ( + {img.alt} + ) + : walletDisplayName(name)} +
+ ) +} diff --git a/wallets/client/components/logger.js b/wallets/client/components/logger.js new file mode 100644 index 00000000..08d787d4 --- /dev/null +++ b/wallets/client/components/logger.js @@ -0,0 +1,145 @@ +import { Button } from 'react-bootstrap' +import styles from '@/styles/logger.module.css' +import { useWalletLogs, useDeleteWalletLogs } from '@/wallets/client/hooks' +import { useCallback, useEffect, useState, Fragment } from 'react' +import { timeSince } from '@/lib/time' +import classNames from 'classnames' +import { ModalClosedError } from '@/components/modal' + +// TODO(wallet-v2): +// when we delete logs for a protocol, the cache is not updated +// so when we go to all wallet logs, we still see the deleted logs until the query is refetched + +export function WalletLogs ({ protocol, className }) { + const { logs, loadMore, hasMore, loading, clearLogs } = useWalletLogs(protocol) + const deleteLogs = useDeleteWalletLogs(protocol) + + const onDelete = useCallback(async () => { + try { + await deleteLogs() + clearLogs() + } catch (err) { + if (err instanceof ModalClosedError) { + return + } + console.error('error deleting logs:', err) + } + }, [deleteLogs, clearLogs]) + + const embedded = !!protocol + + return ( + <> +
+ clear logs + +
+
+ {logs.map((log, i) => ( + + ))} + {loading + ?
loading...
+ : logs.length === 0 &&
empty
} + {hasMore + ?
+ :
------ start of logs ------
} +
+ + ) +} + +export function LogMessage ({ tag, level, message, context, ts }) { + const [show, setShow] = useState(false) + + let className + switch (level.toLowerCase()) { + case 'ok': + case 'success': + level = 'ok' + className = 'text-success'; break + case 'error': + className = 'text-danger'; break + case 'warning': + level = 'warn' + className = 'text-warning'; break + default: + className = 'text-info' + } + + const filtered = context + ? Object.keys(context) + .filter(key => !['send', 'recv', 'status'].includes(key)) + .reduce((obj, key) => { + obj[key] = context[key] + return obj + }, {}) + : {} + + const hasContext = context && Object.keys(filtered).length > 0 + + const handleClick = () => { + if (hasContext) { setShow(show => !show) } + } + + const style = hasContext ? { cursor: 'pointer' } : { cursor: 'inherit' } + const indicator = hasContext ? (show ? '-' : '+') : <> + + // TODO(wallet-v2): show invoice context + + return ( + <> +
+ +
{`[${nameToTag(tag)}]`}
+
{level}
+
{message}
+
{indicator}
+
+ {show && hasContext && ( +
+ {Object.entries(filtered) + .map(([key, value], i) => { + return ( + +
{key}:
+
{value}
+
+ ) + })} +
+ )} + + ) +} + +function nameToTag (name) { + switch (name) { + case undefined: return 'system' + default: return name.toLowerCase() + } +} + +function TimeSince ({ timestamp }) { + const [time, setTime] = useState(timeSince(new Date(timestamp))) + + useEffect(() => { + const timer = setInterval(() => { + setTime(timeSince(new Date(timestamp))) + }, 1000) + + return () => clearInterval(timer) + }, [timestamp]) + + return
{time}
+} diff --git a/wallets/client/components/passphrase.js b/wallets/client/components/passphrase.js new file mode 100644 index 00000000..01a65ee7 --- /dev/null +++ b/wallets/client/components/passphrase.js @@ -0,0 +1,37 @@ +import React from 'react' +import { CopyButton } from '@/components/form' +import { QRCodeSVG } from 'qrcode.react' +import styles from '@/styles/wallet.module.css' + +export function Passphrase ({ passphrase }) { + const words = passphrase.trim().split(/\s+/) + return ( + <> +

+ Make sure to copy your passphrase now. +

+

+ This is the only time we will show it to you. +

+
+ +
+
+ {words.map((word, index) => ( +
+ {index + 1}. + + {word} +
+ ))} +
+
+ +
+ + ) +} diff --git a/wallets/client/components/search.js b/wallets/client/components/search.js new file mode 100644 index 00000000..9ef673e3 --- /dev/null +++ b/wallets/client/components/search.js @@ -0,0 +1,41 @@ +import { useCallback, useState } from 'react' +import { Form, InputGroup, Button } from 'react-bootstrap' +import SearchIcon from '@/svgs/search-line.svg' + +function fuzzySearch (query) { + return (text) => { + const pattern = query.toLowerCase().split('').join('.*') + const regex = new RegExp(pattern) + return regex.test(text.toLowerCase()) + } +} + +export function WalletSearch ({ setSearchFilter }) { + const [searchQuery, setSearchQuery] = useState('') + + const onChange = useCallback((e) => { + const query = e.target.value + setSearchQuery(query) + setSearchFilter(() => fuzzySearch(query)) + }, []) + + return ( +
+
+
+ + + + +
+
+
+ ) +} diff --git a/wallets/client/context/dnd.js b/wallets/client/context/dnd.js new file mode 100644 index 00000000..198810ed --- /dev/null +++ b/wallets/client/context/dnd.js @@ -0,0 +1,235 @@ +import { createContext, useContext, useCallback, useReducer, useState } from 'react' + +const DndContext = createContext(null) +const DndDispatchContext = createContext(null) + +export const DRAG_START = 'DRAG_START' +export const DRAG_ENTER = 'DRAG_ENTER' +export const DRAG_DROP = 'DRAG_DROP' +export const DRAG_END = 'DRAG_END' +export const DRAG_LEAVE = 'DRAG_LEAVE' + +const initialState = { + isDragging: false, + dragIndex: null, + dragOverIndex: null, + items: [] +} + +function useDndState () { + const context = useContext(DndContext) + if (!context) { + throw new Error('useDndState must be used within a DndProvider') + } + return context +} + +function useDndDispatch () { + const context = useContext(DndDispatchContext) + if (!context) { + throw new Error('useDndDispatch must be used within a DndProvider') + } + return context +} + +export function useDndHandlers (index) { + const dispatch = useDndDispatch() + const { isDragging, dragOverIndex, dragIndex } = useDndState() + const [isTouchDragging, setIsTouchDragging] = useState(false) + const [touchStartY, setTouchStartY] = useState(0) + const [touchStartX, setTouchStartX] = useState(0) + + const isBeingDragged = (isDragging || isTouchDragging) && dragIndex === index + const isDragOver = (isDragging || isTouchDragging) && dragOverIndex === index && dragIndex !== index + + const handleDragStart = useCallback((e) => { + e.dataTransfer.effectAllowed = 'move' + e.dataTransfer.setData('text/html', e.target.outerHTML) + e.dataTransfer.setData('text/plain', index.toString()) + + // Remove browser default drag image by setting it to an invisible element + const invisibleElement = document.createElement('div') + invisibleElement.style.width = '1px' + invisibleElement.style.height = '1px' + invisibleElement.style.opacity = '0' + invisibleElement.style.position = 'absolute' + invisibleElement.style.top = '-9999px' + invisibleElement.style.left = '-9999px' + document.body.appendChild(invisibleElement) + e.dataTransfer.setDragImage(invisibleElement, 0, 0) + + // Remove the invisible element after a short delay + setTimeout(() => { + if (document.body.contains(invisibleElement)) { + document.body.removeChild(invisibleElement) + } + }, 100) + + dispatch({ type: DRAG_START, index }) + }, [index, dispatch]) + + const handleDragOver = useCallback((e) => { + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + }, []) + + const handleDragEnter = useCallback((e) => { + e.preventDefault() + dispatch({ type: DRAG_ENTER, index }) + }, [index, dispatch]) + + const handleDragLeave = useCallback((e) => { + e.preventDefault() + // Only clear if we're leaving the element (not entering a child) + if (!e.currentTarget.contains(e.relatedTarget)) { + dispatch({ type: DRAG_LEAVE }) + } + }, [dispatch]) + + const handleDrop = useCallback((e) => { + e.preventDefault() + const draggedIndex = parseInt(e.dataTransfer.getData('text/plain')) + if (draggedIndex !== index) { + dispatch({ type: DRAG_DROP, fromIndex: draggedIndex, toIndex: index }) + } + }, [index, dispatch]) + + const handleDragEnd = useCallback(() => { + dispatch({ type: DRAG_END }) + }, [dispatch]) + + // Touch event handlers for mobile + const handleTouchStart = useCallback((e) => { + if (e.touches.length === 1) { + const touch = e.touches[0] + setTouchStartX(touch.clientX) + setTouchStartY(touch.clientY) + setIsTouchDragging(false) + } + }, []) + + const handleTouchMove = useCallback((e) => { + if (e.touches.length === 1) { + const touch = e.touches[0] + const deltaX = Math.abs(touch.clientX - touchStartX) + const deltaY = Math.abs(touch.clientY - touchStartY) + + // Start dragging if moved more than 10px in any direction + if (!isTouchDragging && (deltaX > 10 || deltaY > 10)) { + setIsTouchDragging(true) + dispatch({ type: DRAG_START, index }) + } + + if (isTouchDragging) { + // Find the element under the touch point + const elementUnderTouch = document.elementFromPoint(touch.clientX, touch.clientY) + if (elementUnderTouch) { + const element = elementUnderTouch.closest('[data-index]') + if (element) { + const elementIndex = parseInt(element.dataset.index) + if (elementIndex !== index) { + dispatch({ type: DRAG_ENTER, index: elementIndex }) + } + } + } + } + } + }, [touchStartX, touchStartY, isTouchDragging, index, dispatch]) + + const handleTouchEnd = useCallback((e) => { + if (isTouchDragging) { + const touch = e.changedTouches[0] + const elementUnderTouch = document.elementFromPoint(touch.clientX, touch.clientY) + + if (elementUnderTouch) { + const element = elementUnderTouch.closest('[data-index]') + if (element) { + const elementIndex = parseInt(element.dataset.index) + if (elementIndex !== index) { + dispatch({ type: DRAG_DROP, fromIndex: index, toIndex: elementIndex }) + } + } + } + + setIsTouchDragging(false) + dispatch({ type: DRAG_END }) + } + }, [isTouchDragging, index, dispatch]) + + return { + handleDragStart, + handleDragOver, + handleDragEnter, + handleDragLeave, + handleDrop, + handleDragEnd, + handleTouchStart, + handleTouchMove, + handleTouchEnd, + isBeingDragged, + isDragOver + } +} + +export function DndProvider ({ children, items, onReorder }) { + const [state, dispatch] = useReducer(dndReducer, { ...initialState, items }) + + const dispatchWithCallback = useCallback((action) => { + if (action.type !== DRAG_DROP) { + dispatch(action) + return + } + + const { fromIndex, toIndex } = action + if (fromIndex === toIndex) { + // nothing changed, just dispatch action but don't run onReorder callback + dispatch(action) + return + } + + const newItems = [...items] + const [movedItem] = newItems.splice(fromIndex, 1) + newItems.splice(toIndex, 0, movedItem) + onReorder(newItems) + }, [items, onReorder]) + + return ( + + + {children} + + + ) +} + +function dndReducer (state, action) { + switch (action.type) { + case DRAG_START: + return { + ...state, + isDragging: true, + dragIndex: action.index, + dragOverIndex: null + } + case DRAG_ENTER: + return { + ...state, + dragOverIndex: action.index + } + case DRAG_LEAVE: + return { + ...state, + dragOverIndex: null + } + case DRAG_DROP: + case DRAG_END: + return { + ...state, + isDragging: false, + dragIndex: null, + dragOverIndex: null + } + default: + return state + } +} diff --git a/wallets/client/context/hooks.js b/wallets/client/context/hooks.js new file mode 100644 index 00000000..a2c78a99 --- /dev/null +++ b/wallets/client/context/hooks.js @@ -0,0 +1,245 @@ +import { useCallback, useEffect, useState } from 'react' +import { useLazyQuery } from '@apollo/client' +import { FAILED_INVOICES } from '@/fragments/invoice' +import { NORMAL_POLL_INTERVAL } from '@/lib/constants' +import useInvoice from '@/components/use-invoice' +import { useMe } from '@/components/me' +import { + useWalletsQuery, useWalletPayment, useGenerateRandomKey, useSetKey, useLoadKey, useLoadOldKey, + useWalletMigrationMutation, CryptoKeyRequiredError, useIsWrongKey +} from '@/wallets/client/hooks' +import { WalletConfigurationError } from '@/wallets/client/errors' +import { SET_WALLETS, WRONG_KEY, KEY_MATCH, NO_KEY, useWalletsDispatch } from '@/wallets/client/context' +import { useIndexedDB } from '@/components/use-indexeddb' + +export function useServerWallets () { + const dispatch = useWalletsDispatch() + const query = useWalletsQuery() + + useEffect(() => { + if (query.error) { + console.error('failed to fetch wallets:', query.error) + return + } + if (query.loading) return + dispatch({ type: SET_WALLETS, wallets: query.data.wallets }) + }, [query]) +} + +export function useKeyCheck () { +} + +export function useAutomatedRetries () { + const waitForWalletPayment = useWalletPayment() + const invoiceHelper = useInvoice() + const [getFailedInvoices] = useLazyQuery(FAILED_INVOICES, { fetchPolicy: 'network-only', nextFetchPolicy: 'network-only' }) + const { me } = useMe() + + const retry = useCallback(async (invoice) => { + const newInvoice = await invoiceHelper.retry({ ...invoice, newAttempt: true }) + + try { + await waitForWalletPayment(newInvoice) + } catch (err) { + if (err instanceof WalletConfigurationError) { + // consume attempt by canceling invoice + await invoiceHelper.cancel(newInvoice) + } + throw err + } + }, [invoiceHelper, waitForWalletPayment]) + + useEffect(() => { + // we always retry failed invoices, even if the user has no wallets on any client + // to make sure that failed payments will always show up in notifications eventually + + if (!me) return + + const retryPoll = async () => { + let failedInvoices + try { + const { data, error } = await getFailedInvoices() + if (error) throw error + failedInvoices = data.failedInvoices + } catch (err) { + console.error('failed to fetch invoices to retry:', err) + return + } + + for (const inv of failedInvoices) { + try { + await retry(inv) + } catch (err) { + // some retries are expected to fail since only one client at a time is allowed to retry + // these should show up as 'invoice not found' errors + console.error('retry failed:', err) + } + } + } + + let timeout, stopped + const queuePoll = () => { + timeout = setTimeout(async () => { + try { + await retryPoll() + } catch (err) { + // every error should already be handled in retryPoll + // but this catch is a safety net to not trigger an unhandled promise rejection + console.error('retry poll failed:', err) + } + if (!stopped) queuePoll() + }, NORMAL_POLL_INTERVAL) + } + + const stopPolling = () => { + stopped = true + clearTimeout(timeout) + } + + queuePoll() + return stopPolling + }, [me?.id, getFailedInvoices, retry]) +} + +export function useKeyInit () { + const { me } = useMe() + + const dispatch = useWalletsDispatch() + const wrongKey = useIsWrongKey() + + useEffect(() => { + if (typeof window.indexedDB === 'undefined') { + dispatch({ type: NO_KEY }) + } else if (wrongKey) { + dispatch({ type: WRONG_KEY }) + } else { + dispatch({ type: KEY_MATCH }) + } + }, [wrongKey, dispatch]) + + const generateRandomKey = useGenerateRandomKey() + const setKey = useSetKey() + const loadKey = useLoadKey() + const loadOldKey = useLoadOldKey() + const [db, setDb] = useState(null) + const { open } = useIndexedDB() + + useEffect(() => { + if (!me?.id) return + let db + + async function openDb () { + db = await open() + setDb(db) + } + openDb() + + return () => { + db?.close() + setDb(null) + } + }, [me?.id, open]) + + useEffect(() => { + if (!me?.id || !db) return + + async function keyInit () { + try { + // TODO(wallet-v2): remove migration code + // and delete the old IndexedDB after wallet v2 has been released for some time + const oldKeyAndHash = await loadOldKey() + if (oldKeyAndHash) { + // return key found in old db and save it to new db + await setKey(oldKeyAndHash) + return + } + + // create random key before opening transaction in case we need it + // and because we can't run async code in a transaction because it will close the transaction + // see https://javascript.info/indexeddb#transactions-autocommit + const { key: randomKey, hash: randomHash } = await generateRandomKey() + + // run read and write in one transaction to avoid race conditions + const { key, hash } = await new Promise((resolve, reject) => { + const tx = db.transaction('vault', 'readwrite') + const read = tx.objectStore('vault').get('key') + + read.onerror = () => { + reject(read.error) + } + + read.onsuccess = () => { + if (read.result) { + // return key+hash found in db + return resolve(read.result) + } + + // no key found, write and return generated random key + const write = tx.objectStore('vault').put({ key: randomKey, hash: randomHash }, 'key') + + write.onerror = () => { + reject(write.error) + } + + write.onsuccess = (event) => { + // return key+hash we just wrote to db + resolve({ key: randomKey, hash: randomHash }) + } + } + }) + + await setKey({ key, hash }) + } catch (err) { + console.error('key init failed:', err) + } + } + keyInit() + }, [me?.id, db, generateRandomKey, loadOldKey, setKey, loadKey]) +} + +// TODO(wallet-v2): remove migration code +// ============================================================= +// ****** Below is the migration code for WALLET v1 -> v2 ****** +// remove when we can assume migration is complete (if ever) +// ============================================================= + +export function useWalletMigration () { + const { me } = useMe() + const { migrate: walletMigration, ready } = useWalletMigrationMutation() + + useEffect(() => { + if (!me?.id || !ready) return + + async function migrate () { + const localWallets = Object.entries(window.localStorage) + .filter(([key]) => key.startsWith('wallet:')) + .filter(([key]) => key.split(':').length < 3 || key.endsWith(me.id)) + .reduce((acc, [key, value]) => { + try { + const config = JSON.parse(value) + acc.push({ key, ...config }) + } catch (err) { + console.error(`useLocalWallets: ${key}: invalid JSON:`, err) + } + return acc + }, []) + + await Promise.allSettled( + localWallets.map(async ({ key, ...localWallet }) => { + const name = key.split(':')[1].toUpperCase() + try { + await walletMigration({ ...localWallet, name }) + window.localStorage.removeItem(key) + } catch (err) { + if (err instanceof CryptoKeyRequiredError) { + // key not set yet, skip this wallet + return + } + console.error(`${name}: wallet migration failed:`, err) + } + }) + ) + } + migrate() + }, [ready, me?.id, walletMigration]) +} diff --git a/wallets/client/context/index.js b/wallets/client/context/index.js new file mode 100644 index 00000000..c2d2ca8a --- /dev/null +++ b/wallets/client/context/index.js @@ -0,0 +1,7 @@ +import WalletsProvider from './provider' + +export * from './provider' +export * from './dnd' +export * from './reducer' + +export default WalletsProvider diff --git a/wallets/client/context/provider.js b/wallets/client/context/provider.js new file mode 100644 index 00000000..321e2e3d --- /dev/null +++ b/wallets/client/context/provider.js @@ -0,0 +1,81 @@ +import { createContext, useContext, useReducer } from 'react' +import walletsReducer, { Status } from './reducer' +import { useServerWallets, useKeyCheck, useAutomatedRetries, useKeyInit, useWalletMigration } from './hooks' +import { WebLnProvider } from '@/wallets/lib/protocols/webln' + +// https://react.dev/learn/scaling-up-with-reducer-and-context +const WalletsContext = createContext(null) +const WalletsDispatchContext = createContext(null) + +export function useWallets () { + const { wallets } = useContext(WalletsContext) + return wallets +} + +export function useTemplates () { + const { templates } = useContext(WalletsContext) + return templates +} + +export function useLoading () { + const { status } = useContext(WalletsContext) + return status === Status.LOADING_WALLETS +} + +export function useStatus () { + const { status } = useContext(WalletsContext) + return status +} + +export function useWalletsDispatch () { + return useContext(WalletsDispatchContext) +} + +export function useKey () { + const { key } = useContext(WalletsContext) + return key +} + +export function useKeyHash () { + const { keyHash } = useContext(WalletsContext) + return keyHash +} + +export default function WalletsProvider ({ children }) { + const [state, dispatch] = useReducer(walletsReducer, { + status: Status.LOADING_WALLETS, + wallets: [], + templates: [], + key: null, + keyHash: null + }) + + return ( + + + + + {children} + + + + + ) +} + +function WalletHooks ({ children }) { + useServerWallets() + useKeyCheck() + useAutomatedRetries() + useKeyInit() + + // TODO(wallet-v2): remove migration code + // ============================================================= + // ****** Below is the migration code for WALLET v1 -> v2 ****** + // remove when we can assume migration is complete (if ever) + // ============================================================= + + useWalletMigration() + + return children +} diff --git a/wallets/client/context/reducer.js b/wallets/client/context/reducer.js new file mode 100644 index 00000000..9b2d3733 --- /dev/null +++ b/wallets/client/context/reducer.js @@ -0,0 +1,73 @@ +import { isTemplate, isWallet } from '@/wallets/lib/util' + +// states that dictate if we show a button or wallets on the wallets page +export const Status = { + LOADING_WALLETS: 'LOADING_WALLETS', + NO_WALLETS: 'NO_WALLETS', + HAS_WALLETS: 'HAS_WALLETS', + PASSPHRASE_REQUIRED: 'PASSPHRASE_REQUIRED', + WALLETS_UNAVAILABLE: 'WALLETS_UNAVAILABLE' +} + +// wallet actions +export const SET_WALLETS = 'SET_WALLETS' +export const SET_KEY = 'SET_KEY' +export const WRONG_KEY = 'WRONG_KEY' +export const KEY_MATCH = 'KEY_MATCH' +export const NO_KEY = 'KEY_UNAVAILABLE' + +export default function reducer (state, action) { + switch (action.type) { + case SET_WALLETS: { + const wallets = action.wallets + .filter(isWallet) + .sort((a, b) => a.priority === b.priority ? a.id - b.id : a.priority - b.priority) + const templates = action.wallets + .filter(isTemplate) + .sort((a, b) => a.name.localeCompare(b.name)) + return { + ...state, + status: statusLocked(state.status) + ? state.status + : walletStatus(wallets), + wallets, + templates + } + } + case SET_KEY: + return { + ...state, + key: action.key, + keyHash: action.hash + } + case WRONG_KEY: + return { + ...state, + status: Status.PASSPHRASE_REQUIRED + } + case KEY_MATCH: + return { + ...state, + status: state.status === Status.LOADING_WALLETS + ? state.status + : walletStatus(state.wallets) + } + case NO_KEY: + return { + ...state, + status: Status.WALLETS_UNAVAILABLE + } + default: + return state + } +} + +function statusLocked (status) { + return [Status.PASSPHRASE_REQUIRED, Status.WALLETS_UNAVAILABLE].includes(status) +} + +function walletStatus (wallets) { + return wallets.length > 0 + ? Status.HAS_WALLETS + : Status.NO_WALLETS +} diff --git a/wallets/errors.js b/wallets/client/errors.js similarity index 100% rename from wallets/errors.js rename to wallets/client/errors.js diff --git a/wallets/client/fragments/index.js b/wallets/client/fragments/index.js new file mode 100644 index 00000000..3410da85 --- /dev/null +++ b/wallets/client/fragments/index.js @@ -0,0 +1,2 @@ +export * from './protocol' +export * from './wallet' diff --git a/wallets/client/fragments/protocol.js b/wallets/client/fragments/protocol.js new file mode 100644 index 00000000..65b08503 --- /dev/null +++ b/wallets/client/fragments/protocol.js @@ -0,0 +1,111 @@ +import { gql } from '@apollo/client' + +export const REMOVE_WALLET_PROTOCOL = gql` + mutation removeWalletProtocol($id: ID!) { + removeWalletProtocol(id: $id) + } +` + +export const UPSERT_WALLET_SEND_LNBITS = gql` + mutation upsertWalletSendLNbits($walletId: ID, $templateName: ID, $enabled: Boolean!, $url: String!, $apiKey: VaultEntryInput!) { + upsertWalletSendLNbits(walletId: $walletId, templateName: $templateName, enabled: $enabled, url: $url, apiKey: $apiKey) { + id + } + } +` + +export const UPSERT_WALLET_RECEIVE_LNBITS = gql` + mutation upsertWalletRecvLNbits($walletId: ID, $templateName: ID, $enabled: Boolean!, $networkTests: Boolean, $url: String!, $apiKey: String!) { + upsertWalletRecvLNbits(walletId: $walletId, templateName: $templateName, enabled: $enabled, networkTests: $networkTests, url: $url, apiKey: $apiKey) { + id + } + } +` + +export const UPSERT_WALLET_SEND_PHOENIXD = gql` + mutation upsertWalletSendPhoenixd($walletId: ID, $templateName: ID, $enabled: Boolean!, $url: String!, $apiKey: VaultEntryInput!) { + upsertWalletSendPhoenixd(walletId: $walletId, templateName: $templateName, enabled: $enabled, url: $url, apiKey: $apiKey) { + id + } + } +` + +export const UPSERT_WALLET_RECEIVE_PHOENIXD = gql` + mutation upsertWalletRecvPhoenixd($walletId: ID, $templateName: ID, $enabled: Boolean!, $networkTests: Boolean, $url: String!, $apiKey: String!) { + upsertWalletRecvPhoenixd(walletId: $walletId, templateName: $templateName, enabled: $enabled, networkTests: $networkTests, url: $url, apiKey: $apiKey) { + id + } + } +` + +export const UPSERT_WALLET_SEND_BLINK = gql` + mutation upsertWalletSendBlink($walletId: ID, $templateName: ID, $enabled: Boolean!, $currency: VaultEntryInput!, $apiKey: VaultEntryInput!) { + upsertWalletSendBlink(walletId: $walletId, templateName: $templateName, enabled: $enabled, currency: $currency, apiKey: $apiKey) { + id + } + } +` + +export const UPSERT_WALLET_RECEIVE_BLINK = gql` + mutation upsertWalletRecvBlink($walletId: ID, $templateName: ID, $enabled: Boolean!, $networkTests: Boolean, $currency: String!, $apiKey: String!) { + upsertWalletRecvBlink(walletId: $walletId, templateName: $templateName, enabled: $enabled, networkTests: $networkTests, currency: $currency, apiKey: $apiKey) { + id + } + } +` + +export const UPSERT_WALLET_RECEIVE_LIGHTNING_ADDRESS = gql` + mutation upsertWalletRecvLightningAddress($walletId: ID, $templateName: ID, $enabled: Boolean!, $networkTests: Boolean, $address: String!) { + upsertWalletRecvLightningAddress(walletId: $walletId, templateName: $templateName, enabled: $enabled, networkTests: $networkTests, address: $address) { + id + } + } +` + +export const UPSERT_WALLET_SEND_NWC = gql` + mutation upsertWalletSendNWC($walletId: ID, $templateName: ID, $enabled: Boolean!, $url: VaultEntryInput!) { + upsertWalletSendNWC(walletId: $walletId, templateName: $templateName, enabled: $enabled, url: $url) { + id + } + } +` + +export const UPSERT_WALLET_RECEIVE_NWC = gql` + mutation upsertWalletRecvNWC($walletId: ID, $templateName: ID, $enabled: Boolean!, $networkTests: Boolean, $url: String!) { + upsertWalletRecvNWC(walletId: $walletId, templateName: $templateName, enabled: $enabled, networkTests: $networkTests, url: $url) { + id + } + } +` + +export const UPSERT_WALLET_RECEIVE_CLN_REST = gql` + mutation upsertWalletRecvCLNRest($walletId: ID, $templateName: ID, $enabled: Boolean!, $networkTests: Boolean, $socket: String!, $rune: String!, $cert: String) { + upsertWalletRecvCLNRest(walletId: $walletId, templateName: $templateName, enabled: $enabled, networkTests: $networkTests, socket: $socket, rune: $rune, cert: $cert) { + id + } + } +` + +export const UPSERT_WALLET_RECEIVE_LNDGRPC = gql` + mutation upsertWalletRecvLNDGRPC($walletId: ID, $templateName: ID, $enabled: Boolean!, $networkTests: Boolean, $socket: String!, $macaroon: String!, $cert: String) { + upsertWalletRecvLNDGRPC(walletId: $walletId, templateName: $templateName, enabled: $enabled, networkTests: $networkTests, socket: $socket, macaroon: $macaroon, cert: $cert) { + id + } + } +` + +export const UPSERT_WALLET_SEND_LNC = gql` + mutation upsertWalletSendLNC($walletId: ID, $templateName: ID, $enabled: Boolean!, $pairingPhrase: VaultEntryInput!, $localKey: VaultEntryInput!, $remoteKey: VaultEntryInput!, $serverHost: VaultEntryInput!) { + upsertWalletSendLNC(walletId: $walletId, templateName: $templateName, enabled: $enabled, pairingPhrase: $pairingPhrase, localKey: $localKey, remoteKey: $remoteKey, serverHost: $serverHost) { + id + } + } +` + +export const UPSERT_WALLET_SEND_WEBLN = gql` + mutation upsertWalletSendWebLN($walletId: ID, $templateName: ID, $enabled: Boolean!) { + upsertWalletSendWebLN(walletId: $walletId, templateName: $templateName, enabled: $enabled) { + id + } + } +` diff --git a/wallets/client/fragments/wallet.js b/wallets/client/fragments/wallet.js new file mode 100644 index 00000000..5ef7fc64 --- /dev/null +++ b/wallets/client/fragments/wallet.js @@ -0,0 +1,259 @@ +import { gql } from '@apollo/client' + +const VAULT_ENTRY_FIELDS = gql` + fragment VaultEntryFields on VaultEntry { + id + iv + value + } +` + +export const CLEAR_VAULT = gql` + mutation ClearVault { + clearVault + } +` + +const WALLET_PROTOCOL_FIELDS = gql` + ${VAULT_ENTRY_FIELDS} + # need to use field aliases because of https://github.com/graphql/graphql-js/issues/53 + fragment WalletProtocolFields on WalletProtocol { + id + name + send + enabled + config { + __typename + ... on WalletSendNWC { + id + encryptedUrl: url { + ...VaultEntryFields + } + } + ... on WalletSendLNbits { + id + url + encryptedApiKey: apiKey { + ...VaultEntryFields + } + } + ... on WalletSendPhoenixd { + id + url + encryptedApiKey: apiKey { + ...VaultEntryFields + } + } + ... on WalletSendBlink { + id + encryptedCurrency: currency { + ...VaultEntryFields + } + encryptedApiKey: apiKey { + ...VaultEntryFields + } + } + ... on WalletSendWebLN { + id + } + ... on WalletSendLNC { + id + encryptedPairingPhrase: pairingPhrase { + ...VaultEntryFields + } + encryptedLocalKey: localKey { + ...VaultEntryFields + } + encryptedRemoteKey: remoteKey { + ...VaultEntryFields + } + encryptedServerHost: serverHost { + ...VaultEntryFields + } + } + ... on WalletRecvNWC { + id + url + } + ... on WalletRecvLNbits { + id + url + apiKey + } + ... on WalletRecvPhoenixd { + id + url + apiKey + } + ... on WalletRecvBlink { + id + currency + apiKey + } + ... on WalletRecvLightningAddress { + id + address + } + ... on WalletRecvCLNRest { + id + socket + rune + cert + } + ... on WalletRecvLNDGRPC { + id + socket + macaroon + cert + } + } + } +` + +const WALLET_TEMPLATE_FIELDS = gql` + fragment WalletTemplateFields on WalletTemplate { + # need to use field alias because of https://github.com/graphql/graphql-js/issues/53 + id: name + send + receive + protocols { + id + name + send + } + } +` + +const USER_WALLET_FIELDS = gql` + ${WALLET_PROTOCOL_FIELDS} + ${WALLET_TEMPLATE_FIELDS} + fragment WalletFields on Wallet { + id + name + priority + send + receive + protocols { + ...WalletProtocolFields + } + template { + ...WalletTemplateFields + } + } +` + +const WALLET_OR_TEMPLATE_FIELDS = gql` + ${USER_WALLET_FIELDS} + ${WALLET_TEMPLATE_FIELDS} + fragment WalletOrTemplateFields on WalletOrTemplate { + ... on Wallet { + ...WalletFields + } + ... on WalletTemplate { + ...WalletTemplateFields + } + } +` + +export const WALLETS = gql` + ${WALLET_OR_TEMPLATE_FIELDS} + query Wallets { + wallets { + ...WalletOrTemplateFields + } + } +` + +export const WALLET = gql` + ${WALLET_OR_TEMPLATE_FIELDS} + query Wallet($id: ID, $name: String) { + wallet(id: $id, name: $name) { + ...WalletOrTemplateFields + } + } +` + +export const REMOVE_WALLET = gql` + mutation removeWallet($id: ID!) { + removeWallet(id: $id) + } +` + +export const SET_WALLET_PRIORITIES = gql` + mutation SetWalletPriorities($priorities: [WalletPriorityUpdate!]!) { + setWalletPriorities(priorities: $priorities) + } +` + +export const UPDATE_WALLET_ENCRYPTION = gql` + mutation UpdateWalletEncryption($keyHash: String!, $wallets: [WalletEncryptionUpdate!]!) { + updateWalletEncryption(keyHash: $keyHash, wallets: $wallets) + } +` + +export const UPDATE_KEY_HASH = gql` + mutation UpdateKeyHash($keyHash: String!) { + updateKeyHash(keyHash: $keyHash) + } +` + +export const RESET_WALLETS = gql` + mutation ResetWallets($newKeyHash: String!) { + resetWallets(newKeyHash: $newKeyHash) + } +` + +export const DISABLE_PASSPHRASE_EXPORT = gql` + mutation DisablePassphraseExport { + disablePassphraseExport + } +` + +export const WALLET_SETTINGS = gql` + query WalletSettings { + walletSettings { + receiveCreditsBelowSats + sendCreditsBelowSats + proxyReceive + autoWithdrawMaxFeePercent + autoWithdrawMaxFeeTotal + autoWithdrawThreshold + } + } +` + +export const SET_WALLET_SETTINGS = gql` + mutation SetWalletSettings($settings: WalletSettingsInput!) { + setWalletSettings(settings: $settings) + } +` + +export const ADD_WALLET_LOG = gql` + mutation AddWalletLog($protocolId: Int!, $level: String!, $message: String!, $timestamp: Date!, $invoiceId: Int) { + addWalletLog(protocolId: $protocolId, level: $level, message: $message, timestamp: $timestamp, invoiceId: $invoiceId) + } +` + +export const WALLET_LOGS = gql` + query WalletLogs($protocolId: Int, $cursor: String) { + walletLogs(protocolId: $protocolId, cursor: $cursor) { + entries { + id + level + message + createdAt + wallet { + name + } + context + } + cursor + } + } +` + +export const DELETE_WALLET_LOGS = gql` + mutation DeleteWalletLogs($protocolId: Int) { + deleteWalletLogs(protocolId: $protocolId) + } +` diff --git a/wallets/client/hooks/crypto.js b/wallets/client/hooks/crypto.js new file mode 100644 index 00000000..d4016ee2 --- /dev/null +++ b/wallets/client/hooks/crypto.js @@ -0,0 +1,355 @@ +import { useCallback, useMemo } from 'react' +import { fromHex, toHex } from '@/lib/hex' +import { useMe } from '@/components/me' +import { useIndexedDB } from '@/components/use-indexeddb' +import { useShowModal } from '@/components/modal' +import { Button } from 'react-bootstrap' +import { Passphrase } from '@/wallets/client/components' +import bip39Words from '@/lib/bip39-words' +import { Form, PasswordInput, SubmitButton } from '@/components/form' +import { object, string } from 'yup' +import { SET_KEY, useKey, useKeyHash, useWalletsDispatch } from '@/wallets/client/context' +import { useDisablePassphraseExport, useUpdateKeyHash, useWalletEncryptionUpdate, useWalletReset } from '@/wallets/client/hooks' +import { useToast } from '@/components/toast' + +export class CryptoKeyRequiredError extends Error { + constructor () { + super('CryptoKey required') + this.name = 'CryptoKeyRequiredError' + } +} + +export function useLoadKey () { + const { get } = useIndexedDB() + + return useCallback(async () => { + return await get('vault', 'key') + }, [get]) +} + +export function useLoadOldKey () { + const { me } = useMe() + const oldDbName = me?.id ? `app:storage:${me?.id}:vault` : undefined + const { get } = useIndexedDB(oldDbName) + + return useCallback(async () => { + return await get('vault', 'key') + }, [get]) +} + +export function useSetKey () { + const { set } = useIndexedDB() + const dispatch = useWalletsDispatch() + const updateKeyHash = useUpdateKeyHash() + + return useCallback(async ({ key, hash }) => { + await set('vault', 'key', { key, hash }) + await updateKeyHash(hash) + dispatch({ type: SET_KEY, key, hash }) + }, [set, dispatch, updateKeyHash]) +} + +export function useEncryption () { + const defaultKey = useKey() + const defaultKeyHash = useKeyHash() + + const encrypt = useCallback( + (value, { key, hash } = {}) => { + const k = key ?? defaultKey + const h = hash ?? defaultKeyHash + if (!k || !h) throw new CryptoKeyRequiredError() + return _encrypt({ key: k, hash: h }, value) + }, [defaultKey, defaultKeyHash]) + + return useMemo(() => ({ + encrypt, + ready: !!defaultKey + }), [encrypt, defaultKey]) +} + +export function useDecryption () { + const key = useKey() + + const decrypt = useCallback(value => { + if (!key) throw new CryptoKeyRequiredError() + return _decrypt(key, value) + }, [key]) + + return useMemo(() => ({ + decrypt, + ready: !!key + }), [decrypt, key]) +} + +export function useRemoteKeyHash () { + const { me } = useMe() + return me?.privates?.vaultKeyHash +} + +export function useIsWrongKey () { + const localHash = useKeyHash() + const remoteHash = useRemoteKeyHash() + return localHash && remoteHash && localHash !== remoteHash +} + +export function useKeySalt () { + // TODO(wallet-v2): random salt + const { me } = useMe() + return `stacker${me?.id}` +} + +export function useShowPassphrase () { + const { me } = useMe() + const showModal = useShowModal() + const generateRandomKey = useGenerateRandomKey() + const updateWalletEncryption = useWalletEncryptionUpdate() + const toaster = useToast() + + const onShow = useCallback(async () => { + let passphrase, key, hash + try { + ({ passphrase, key, hash } = await generateRandomKey()) + await updateWalletEncryption({ key, hash }) + } catch (err) { + toaster.danger('failed to update wallet encryption: ' + err.message) + return + } + showModal( + close => , + { replaceModal: true, keepOpen: true } + ) + }, [showModal, generateRandomKey, updateWalletEncryption, toaster]) + + const cb = useCallback(() => { + showModal(close => ( +
+

+ The next screen will show the passphrase that was used to encrypt your wallets. +

+

+ You will not be able to see the passphrase again. +

+

+ Do you want to see it now? +

+
+ + +
+
+ )) + }, [showModal, onShow]) + + if (!me || !me.privates?.showPassphrase) { + return null + } + + return cb +} + +export function useSavePassphrase () { + const setKey = useSetKey() + const salt = useKeySalt() + const disablePassphraseExport = useDisablePassphraseExport() + + return useCallback(async ({ passphrase }) => { + const { key, hash } = await deriveKey(passphrase, salt) + await setKey({ key, hash }) + await disablePassphraseExport() + }, [setKey, disablePassphraseExport]) +} + +export function useResetPassphrase () { + const showModal = useShowModal() + const walletReset = useWalletReset() + const generateRandomKey = useGenerateRandomKey() + const setKey = useSetKey() + const toaster = useToast() + + const resetPassphrase = useCallback((close) => + async () => { + try { + const { key: randomKey, hash } = await generateRandomKey() + await setKey({ key: randomKey, hash }) + await walletReset({ newKeyHash: hash }) + close() + } catch (err) { + console.error('failed to reset passphrase:', err) + toaster.error('failed to reset passphrase') + } + }, [walletReset, generateRandomKey, setKey, toaster]) + + return useCallback(async () => { + showModal(close => ( +
+

Reset passphrase

+

+ This will delete all your sending credentials. Your credentials for receiving will not be affected. +

+

+ After the reset, you will be issued a new passphrase. +

+
+ + +
+
+ )) + }, [showModal, resetPassphrase]) +} + +const passphraseSchema = ({ hash, salt }) => object().shape({ + passphrase: string().required('required') + .test(async (value, context) => { + const { hash: expectedHash } = await deriveKey(value, salt) + if (hash !== expectedHash) { + return context.createError({ message: 'wrong passphrase' }) + } + return true + }) +}) + +export function usePassphrasePrompt () { + const showModal = useShowModal() + const savePassphrase = useSavePassphrase() + const hash = useRemoteKeyHash() + const salt = useKeySalt() + const showPassphrase = useShowPassphrase() + const resetPassphrase = useResetPassphrase() + + const onSubmit = useCallback((close) => + async ({ passphrase }) => { + await savePassphrase({ passphrase }) + close() + }, [savePassphrase]) + + return useCallback(() => { + showModal(close => ( +
+

Wallet decryption

+

+ Your wallets have been encrypted on another device. Enter your passphrase to use your wallets on this device. +

+

+ {showPassphrase && 'You can find the button to reveal your passphrase above your wallets on the other device.'} +

+

+ Press reset if you lost your passphrase. +

+
+ +
+
+ + + save +
+
+ +
+ )) + }, [showModal, savePassphrase, hash, salt]) +} + +export async function deriveKey (passphrase, salt) { + 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(salt), + // 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 + } +} + +async function _encrypt ({ key, hash }, value) { + // random IVs are _really_ important in GCM: reusing the IV once can lead to catastrophic failure + // see https://crypto.stackexchange.com/questions/26790/how-bad-it-is-using-the-same-iv-twice-with-aes-gcm + // 12 bytes (96 bits) is the recommended IV size for AES-GCM + const iv = window.crypto.getRandomValues(new Uint8Array(12)) + const encoded = new TextEncoder().encode(JSON.stringify(value)) + const encrypted = await window.crypto.subtle.encrypt( + { + name: 'AES-GCM', + iv + }, + key, + encoded + ) + return { + keyHash: hash, + iv: toHex(iv.buffer), + value: toHex(encrypted) + } +} + +async function _decrypt (key, { iv, value }) { + const decrypted = await window.crypto.subtle.decrypt( + { + name: 'AES-GCM', + iv: fromHex(iv) + }, + key, + fromHex(value) + ) + const decoded = new TextDecoder().decode(decrypted) + return JSON.parse(decoded) +} + +export function useGenerateRandomKey () { + const salt = useKeySalt() + + return useCallback(async () => { + const passphrase = generateRandomPassphrase() + const { key, hash } = await deriveKey(passphrase, salt) + return { passphrase, key, hash } + }, [salt]) +} + +function generateRandomPassphrase () { + const rand = new Uint32Array(12) + window.crypto.getRandomValues(rand) + return Array.from(rand).map(i => bip39Words[i % bip39Words.length]).join(' ') +} diff --git a/wallets/client/hooks/image.js b/wallets/client/hooks/image.js new file mode 100644 index 00000000..f4379fdc --- /dev/null +++ b/wallets/client/hooks/image.js @@ -0,0 +1,18 @@ +import useDarkMode from '@/components/dark-mode' +import { walletDisplayName, walletImage } from '@/wallets/lib/util' + +export function useWalletImage (name) { + const [darkMode] = useDarkMode() + + const image = walletImage(name) + if (!image) return null + + let src = typeof image === 'string' ? image : image.src + const alt = typeof image === 'string' ? walletDisplayName(name) : image.alt + const hasDarkMode = typeof image === 'string' ? true : image.darkMode + + if (darkMode && hasDarkMode === false) return null + if (darkMode) src = src.replace(/\.([a-z]{3,4})$/, '-dark.$1') + + return { src, alt } +} diff --git a/wallets/client/hooks/index.js b/wallets/client/hooks/index.js new file mode 100644 index 00000000..80216fb8 --- /dev/null +++ b/wallets/client/hooks/index.js @@ -0,0 +1,8 @@ +export * from './payment' +export * from './image' +export * from './indicator' +export * from './prompt' +export * from './wallet' +export * from './crypto' +export * from './query' +export * from './logger' diff --git a/wallets/client/hooks/indicator.js b/wallets/client/hooks/indicator.js new file mode 100644 index 00000000..c9389679 --- /dev/null +++ b/wallets/client/hooks/indicator.js @@ -0,0 +1,7 @@ +import { useWallets, useLoading } from '@/wallets/client/context' + +export function useWalletIndicator () { + const wallets = useWallets() + const loading = useLoading() + return !loading && wallets.length === 0 +} diff --git a/wallets/client/hooks/logger.js b/wallets/client/hooks/logger.js new file mode 100644 index 00000000..c853304d --- /dev/null +++ b/wallets/client/hooks/logger.js @@ -0,0 +1,227 @@ +import { useMutation, useLazyQuery } from '@apollo/client' +import { ADD_WALLET_LOG, WALLET_LOGS, DELETE_WALLET_LOGS } from '@/wallets/client/fragments' +import { createContext, useCallback, useContext, useMemo, useState, useEffect } from 'react' +import { Button } from 'react-bootstrap' +import { ModalClosedError, useShowModal } from '@/components/modal' +import { useToast } from '@/components/toast' +import { FAST_POLL_INTERVAL } from '@/lib/constants' +import { isTemplate } from '@/wallets/lib/util' + +const TemplateLogsContext = createContext({}) + +export function TemplateLogsProvider ({ children }) { + const [templateLogs, setTemplateLogs] = useState([]) + + const addTemplateLog = useCallback(({ level, message }) => { + // TODO(wallet-v2): Date.now() might return the same value for two logs + // use window.performance.now() instead? + setTemplateLogs(prev => [{ id: Date.now(), level, message, createdAt: new Date() }, ...prev]) + }, []) + + const clearTemplateLogs = useCallback(() => { + setTemplateLogs([]) + }, []) + + const value = useMemo(() => ({ + templateLogs, + addTemplateLog, + clearTemplateLogs + }), [templateLogs, addTemplateLog, clearTemplateLogs]) + + return ( + + {children} + + ) +} + +export function useWalletLoggerFactory () { + const { addTemplateLog } = useContext(TemplateLogsContext) + const [addWalletLog] = useMutation(ADD_WALLET_LOG) + + const log = useCallback(({ protocol, level, message, invoiceId }) => { + console[mapLevelToConsole(level)](`[${protocol.name}] ${message}`) + + if (isTemplate(protocol)) { + // this is a template, so there's no protocol yet to which we could attach logs in the db + addTemplateLog?.({ level, message }) + return + } + + return addWalletLog({ variables: { protocolId: Number(protocol.id), level, message, invoiceId, timestamp: new Date() } }) + .catch(err => { + console.error('error adding wallet log:', err) + }) + }, [addWalletLog, addTemplateLog]) + + return useCallback((protocol, invoice) => { + const invoiceId = invoice ? Number(invoice.id) : null + return { + ok: (message) => { + log({ protocol, level: 'OK', message, invoiceId }) + }, + info: (message) => { + log({ protocol, level: 'INFO', message, invoiceId }) + }, + error: (message) => { + log({ protocol, level: 'ERROR', message, invoiceId }) + }, + warn: (message) => { + log({ protocol, level: 'WARN', message, invoiceId }) + } + } + }, [log]) +} + +export function useWalletLogger (protocol) { + const loggerFactory = useWalletLoggerFactory() + return loggerFactory(protocol) +} + +export function useWalletLogs (protocol) { + const { templateLogs, clearTemplateLogs } = useContext(TemplateLogsContext) + + const [cursor, setCursor] = useState(null) + // if we're configuring a protocol template, there are no logs to fetch + const skip = protocol && isTemplate(protocol) + const [logs, setLogs] = useState(skip ? templateLogs : []) + + // if no protocol was given, we want to fetch all logs + const protocolId = protocol ? Number(protocol.id) : undefined + + const [fetchLogs, { called, loading, error }] = useLazyQuery(WALLET_LOGS, { + variables: { protocolId }, + skip, + fetchPolicy: 'network-only' + }) + + useEffect(() => { + if (skip) return + + const interval = setInterval(async () => { + const { data } = await fetchLogs({ variables: { protocolId } }) + const { entries: updatedLogs, cursor } = data.walletLogs + setLogs(logs => [...updatedLogs.filter(log => !logs.some(l => l.id === log.id)), ...logs]) + if (!called) { + setCursor(cursor) + } + }, FAST_POLL_INTERVAL) + + return () => clearInterval(interval) + }, [fetchLogs, called, skip]) + + const loadMore = useCallback(async () => { + const { data } = await fetchLogs({ variables: { protocolId, cursor } }) + const { entries: cursorLogs, cursor: newCursor } = data.walletLogs + setLogs(logs => [...logs, ...cursorLogs.filter(log => !logs.some(l => l.id === log.id))]) + setCursor(newCursor) + }, [fetchLogs, cursor, protocolId]) + + const clearLogs = useCallback(() => { + setLogs([]) + clearTemplateLogs?.() + setCursor(null) + }, [clearTemplateLogs]) + + return useMemo(() => { + return { + loading: skip ? false : (!called ? true : loading), + logs: skip ? templateLogs : logs, + error, + loadMore, + hasMore: cursor !== null, + clearLogs + } + }, [loading, skip, called, templateLogs, logs, error, loadMore, clearLogs]) +} + +function mapLevelToConsole (level) { + switch (level) { + case 'OK': + case 'INFO': + return 'info' + case 'ERROR': + return 'error' + case 'WARN': + return 'warn' + default: + return 'log' + } +} + +export function useDeleteWalletLogs (protocol) { + const showModal = useShowModal() + + return useCallback(async () => { + return await new Promise((resolve, reject) => { + const onClose = () => { + reject(new ModalClosedError()) + } + + showModal(close => { + const onDelete = () => { + resolve() + close() + } + + const onClose = () => { + reject(new ModalClosedError()) + close() + } + + return ( + + ) + }, { onClose }) + }) + }, [showModal]) +} + +function DeleteWalletLogsObstacle ({ protocol, onClose, onDelete }) { + const toaster = useToast() + const [deleteWalletLogs] = useMutation(DELETE_WALLET_LOGS) + + const deleteLogs = useCallback(async () => { + // there are no logs to delete on the server if protocol is a template + if (protocol && isTemplate(protocol)) return + + await deleteWalletLogs({ + variables: { protocolId: protocol ? Number(protocol.id) : undefined } + }) + }, [protocol, deleteWalletLogs]) + + const onClick = useCallback(async () => { + try { + await deleteLogs() + onDelete() + onClose() + toaster.success('deleted wallet logs') + } catch (err) { + console.error('failed to delete wallet logs:', err) + toaster.danger('failed to delete wallet logs') + } + }, [onClose, deleteLogs, toaster]) + + let prompt = 'Do you really want to delete all wallet logs?' + if (protocol) { + prompt = 'Do you really want to delete all logs of this protocol?' + } + + return ( +
+ {prompt} +
+ cancel + +
+
+ ) +} diff --git a/wallets/payment.js b/wallets/client/hooks/payment.js similarity index 74% rename from wallets/payment.js rename to wallets/client/hooks/payment.js index 1a5a038d..fd8c3592 100644 --- a/wallets/payment.js +++ b/wallets/client/hooks/payment.js @@ -1,23 +1,21 @@ import { useCallback } from 'react' -import { useSendWallets } from '@/wallets' -import { formatSats } from '@/lib/format' +import { useSendProtocols, useWalletLoggerFactory } from '@/wallets/client/hooks' import useInvoice from '@/components/use-invoice' import { FAST_POLL_INTERVAL, WALLET_SEND_PAYMENT_TIMEOUT_MS } from '@/lib/constants' import { AnonWalletError, WalletsNotAvailableError, WalletSenderError, WalletAggregateError, WalletPaymentAggregateError, - WalletNotEnabledError, WalletSendNotConfiguredError, WalletPaymentError, WalletError, WalletReceiverError -} from '@/wallets/errors' -import { canSend } from './common' -import { useWalletLoggerFactory } from './logger' + WalletPaymentError, WalletError, WalletReceiverError +} from '@/wallets/client/errors' import { timeoutSignal, withTimeout } from '@/lib/time' import { useMe } from '@/components/me' +import { formatSats } from '@/lib/format' export function useWalletPayment () { - const wallets = useSendWallets() + const protocols = useSendProtocols() const sendPayment = useSendPayment() - const loggerFactory = useWalletLoggerFactory() const invoiceHelper = useInvoice() const { me } = useMe() + const loggerFactory = useWalletLoggerFactory() return useCallback(async (invoice, { waitFor, updateOnFallback } = {}) => { let aggregateError = new WalletAggregateError([]) @@ -29,25 +27,23 @@ export function useWalletPayment () { } // throw a special error that caller can handle separately if no payment was attempted - if (wallets.length === 0) { + if (protocols.length === 0) { throw new WalletsNotAvailableError() } - for (let i = 0; i < wallets.length; i++) { - const wallet = wallets[i] - const logger = loggerFactory(wallet) - - const { bolt11 } = latestInvoice + for (let i = 0; i < protocols.length; i++) { + const protocol = protocols[i] const controller = invoiceController(latestInvoice, invoiceHelper.isInvoice) - const walletPromise = sendPayment(wallet, logger, latestInvoice) + const logger = loggerFactory(protocol, latestInvoice) + const paymentPromise = sendPayment(protocol, latestInvoice, logger) const pollPromise = controller.wait(waitFor) try { return await new Promise((resolve, reject) => { - // can't await wallet payments since we might pay hold invoices and thus payments might not settle immediately. + // can't await payments since we might pay hold invoices and thus payments might not settle immediately. // that's why we separately check if we received the payment with the invoice controller. - walletPromise.catch(reject) + paymentPromise.catch(reject) pollPromise.then(resolve).catch(reject) }) } catch (err) { @@ -57,7 +53,7 @@ export function useWalletPayment () { if (!(paymentError instanceof WalletError)) { // payment failed for some reason unrelated to wallets (ie invoice expired or was canceled). // bail out of attempting wallets. - logger.error(message, { bolt11 }) + logger.error(message) throw paymentError } @@ -77,11 +73,11 @@ export function useWalletPayment () { if (paymentError instanceof WalletReceiverError) { // if payment failed because of the receiver, use the same wallet again // and log this as info, not error - logger.info('failed to forward payment to receiver, retrying with new invoice', { bolt11 }) + logger.info('failed to forward payment to receiver, retrying with new invoice') i -= 1 } else if (paymentError instanceof WalletPaymentError) { // only log payment errors, not configuration errors - logger.error(message, { bolt11 }) + logger.error(message) } if (paymentError instanceof WalletPaymentError) { @@ -89,8 +85,8 @@ export function useWalletPayment () { await invoiceHelper.cancel(latestInvoice) } - // only create a new invoice if we will try to pay with a wallet again - const retry = paymentError instanceof WalletReceiverError || i < wallets.length - 1 + // only create a new invoice if we will try to pay with a protocol again + const retry = paymentError instanceof WalletReceiverError || i < protocols.length - 1 if (retry) { latestInvoice = await invoiceHelper.retry(latestInvoice, { update: updateOnFallback }) } @@ -105,7 +101,7 @@ export function useWalletPayment () { // if we reach this line, no wallet payment succeeded throw new WalletPaymentAggregateError([aggregateError], latestInvoice) - }, [wallets, invoiceHelper, sendPayment, loggerFactory]) + }, [protocols, invoiceHelper, sendPayment]) } function invoiceController (inv, isInvoice) { @@ -147,30 +143,21 @@ function invoiceController (inv, isInvoice) { } function useSendPayment () { - return useCallback(async (wallet, logger, invoice) => { - if (!wallet.config.enabled) { - throw new WalletNotEnabledError(wallet.def.name) - } - - if (!canSend(wallet)) { - throw new WalletSendNotConfiguredError(wallet.def.name) - } - - const { bolt11, satsRequested } = invoice - - logger.info(`↗ sending payment: ${formatSats(satsRequested)}`, { bolt11 }) + return useCallback(async (protocol, invoice, logger) => { try { - const preimage = await withTimeout( - wallet.def.sendPayment(bolt11, wallet.config, { - logger, - signal: timeoutSignal(WALLET_SEND_PAYMENT_TIMEOUT_MS) - }), + logger.info(`↗ sending payment: ${formatSats(invoice.satsRequested)}`) + await withTimeout( + protocol.sendPayment( + invoice.bolt11, + protocol.config, + { signal: timeoutSignal(WALLET_SEND_PAYMENT_TIMEOUT_MS) } + ), WALLET_SEND_PAYMENT_TIMEOUT_MS) - logger.ok(`↗ payment sent: ${formatSats(satsRequested)}`, { bolt11, preimage }) + logger.ok(`↗ payment sent: ${formatSats(invoice.satsRequested)}`) } catch (err) { // we don't log the error here since we want to handle receiver errors separately const message = err.message || err.toString?.() - throw new WalletSenderError(wallet.def.name, invoice, message) + throw new WalletSenderError(protocol.name, invoice, message) } }, []) } diff --git a/wallets/prompt.js b/wallets/client/hooks/prompt.js similarity index 69% rename from wallets/prompt.js rename to wallets/client/hooks/prompt.js index cf62cfde..5fe0031a 100644 --- a/wallets/prompt.js +++ b/wallets/client/hooks/prompt.js @@ -5,14 +5,12 @@ import { Form, ClientInput, SubmitButton, Checkbox } from '@/components/form' import { useMe } from '@/components/me' import { useShowModal } from '@/components/modal' import Link from 'next/link' -import { useWallet } from '@/wallets/index' -import { useWalletConfigurator } from '@/wallets/config' import styles from '@/styles/wallet.module.css' -import { externalLightningAddressValidator } from '@/lib/validate' -import { autowithdrawInitial } from '@/components/autowithdraw-shared' import { useMutation } from '@apollo/client' import { HIDE_WALLET_RECV_PROMPT_MUTATION } from '@/fragments/users' import { useToast } from '@/components/toast' +import { useLightningAddressUpsert } from '@/wallets/client/hooks/query' +import { protocolClientSchema } from '@/wallets/lib/util' export class WalletPromptClosed extends Error { constructor () { @@ -40,7 +38,6 @@ export function useWalletRecvPrompt () { return useCallback((e) => { return new Promise((resolve, reject) => { - // TODO: check if user told us to not show again if (!me || me.optional?.hasRecvWallet || me.privates?.hideWalletRecvPrompt) return resolve() showModal(onClose => { @@ -60,31 +57,26 @@ export function useWalletRecvPrompt () { }, [!!me, me?.optional?.hasRecvWallet, me?.privates?.hideWalletRecvPrompt, showModal, onAttach, onSkip]) } -const Header = () => ( -
- You need to attach a
- lightning wallet -
- to receive sats -
-) +function Header () { + return ( +
+ You need to attach a
+ lightning wallet +
+ to receive sats +
+ ) +} -const LnAddrForm = ({ onAttach }) => { - const { me } = useMe() - const wallet = useWallet('lightning-address') - const { save } = useWalletConfigurator(wallet) +function LnAddrForm ({ onAttach }) { + const upsert = useLightningAddressUpsert() + const schema = protocolClientSchema({ name: 'LN_ADDR', send: false }) + const initial = { address: '' } - const schema = object({ lnAddr: externalLightningAddressValidator.required('required') }) - - const onSubmit = useCallback(async ({ lnAddr }) => { - await save({ - ...autowithdrawInitial({ me }), - priority: 0, - enabled: true, - address: lnAddr - }, true) + const onSubmit = useCallback(async ({ address }) => { + await upsert({ address }) onAttach() - }, [save]) + }, [upsert, onAttach]) return ( <> @@ -92,10 +84,10 @@ const LnAddrForm = ({ onAttach }) => {
save} /> @@ -104,9 +96,11 @@ const LnAddrForm = ({ onAttach }) => { ) } -const WalletLink = () => visit wallets to set up a different wallet +function WalletLink () { + return visit wallets to set up a different wallet +} -const SkipForm = ({ onSkip }) => { +function SkipForm ({ onSkip }) { const { me } = useMe() const [hideWalletRecvPrompt] = useMutation(HIDE_WALLET_RECV_PROMPT_MUTATION, { update (cache) { @@ -143,9 +137,11 @@ const SkipForm = ({ onSkip }) => { ) } -const Footer = () => ( -
- Stacker News is non-custodial. If you don't attach a wallet, you will receive credits when zapped. - See the FAQ for the details. -
-) +function Footer () { + return ( +
+ Stacker News is non-custodial. If you don't attach a wallet, you will receive credits when zapped. + See the FAQ for the details. +
+ ) +} diff --git a/wallets/client/hooks/query.js b/wallets/client/hooks/query.js new file mode 100644 index 00000000..9e1e97cf --- /dev/null +++ b/wallets/client/hooks/query.js @@ -0,0 +1,514 @@ +import { + WALLET, + UPSERT_WALLET_RECEIVE_BLINK, + UPSERT_WALLET_RECEIVE_CLN_REST, + UPSERT_WALLET_RECEIVE_LIGHTNING_ADDRESS, + UPSERT_WALLET_RECEIVE_LNBITS, + UPSERT_WALLET_RECEIVE_LNDGRPC, + UPSERT_WALLET_RECEIVE_NWC, + UPSERT_WALLET_RECEIVE_PHOENIXD, + UPSERT_WALLET_SEND_BLINK, + UPSERT_WALLET_SEND_LNBITS, + UPSERT_WALLET_SEND_LNC, + UPSERT_WALLET_SEND_NWC, + UPSERT_WALLET_SEND_PHOENIXD, + UPSERT_WALLET_SEND_WEBLN, + WALLETS, + REMOVE_WALLET_PROTOCOL, + UPDATE_WALLET_ENCRYPTION, + RESET_WALLETS, + DISABLE_PASSPHRASE_EXPORT, + SET_WALLET_PRIORITIES, + UPDATE_KEY_HASH +} from '@/wallets/client/fragments' +import { useApolloClient, useMutation, useQuery } from '@apollo/client' +import { useDecryption, useEncryption, useSetKey, useWalletLogger, WalletStatus } from '@/wallets/client/hooks' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + isEncryptedField, isTemplate, isWallet, protocolAvailable, protocolClientSchema, reverseProtocolRelationName +} from '@/wallets/lib/util' +import { protocolTestSendPayment } from '@/wallets/client/protocols' +import { timeoutSignal } from '@/lib/time' +import { WALLET_SEND_PAYMENT_TIMEOUT_MS } from '@/lib/constants' +import { useToast } from '@/components/toast' +import { useMe } from '@/components/me' +import { useWallets, useLoading as useWalletsLoading } from '@/wallets/client/context' + +export function useWalletsQuery () { + const { me } = useMe() + const query = useQuery(WALLETS, { skip: !me }) + const [wallets, setWallets] = useState(null) + + const { decryptWallet, ready } = useWalletDecryption() + + useEffect(() => { + if (!query.data?.wallets || !ready) return + Promise.all( + query.data?.wallets.map(w => decryptWallet(w)) + ) + .then(wallets => wallets.map(protocolCheck)) + .then(wallets => wallets.map(undoFieldAlias)) + .then(wallets => setWallets(wallets)) + .catch(err => { + console.error('failed to decrypt wallets:', err) + setWallets([]) + }) + }, [query.data, decryptWallet, ready]) + + useRefetchOnChange(query.refetch) + + return useMemo(() => ({ + ...query, + loading: !wallets, + data: wallets ? { wallets } : null + }), [query, wallets]) +} + +function protocolCheck (wallet) { + if (isTemplate(wallet)) return wallet + + const protocols = wallet.protocols.map(protocol => { + return { + ...protocol, + enabled: protocol.enabled && protocolAvailable(protocol) + } + }) + + const sendEnabled = protocols.some(p => p.send && p.enabled) + const receiveEnabled = protocols.some(p => !p.send && p.enabled) + + return { + ...wallet, + send: !sendEnabled ? WalletStatus.DISABLED : wallet.send, + receive: !receiveEnabled ? WalletStatus.DISABLED : wallet.receive, + protocols + } +} + +function undoFieldAlias ({ id, ...wallet }) { + // Just like for encrypted fields, we have to use a field alias for the name field of templates + // because of https://github.com/graphql/graphql-js/issues/53. + // We undo this here so this only affects the GraphQL layer but not the rest of the code. + if (isTemplate(wallet)) { + return { ...wallet, name: id } + } + + if (!wallet.template) return wallet + + const { id: templateId, ...template } = wallet.template + return { id, ...wallet, template: { name: templateId, ...template } } +} + +function useRefetchOnChange (refetch) { + const { me } = useMe() + + useEffect(() => { + if (!me?.id) return + + refetch() + }, [refetch, me?.id, me?.privates?.walletsUpdatedAt]) +} + +export function useWalletQuery ({ id, name }) { + const { me } = useMe() + const query = useQuery(WALLET, { variables: { id, name }, skip: !me }) + const [wallet, setWallet] = useState(null) + + const { decryptWallet, ready } = useWalletDecryption() + + useEffect(() => { + if (!query.data?.wallet || !ready) return + decryptWallet(query.data?.wallet) + .then(protocolCheck) + .then(undoFieldAlias) + .then(wallet => setWallet(wallet)) + .catch(err => { + console.error('failed to decrypt wallet:', err) + }) + }, [query.data, decryptWallet, ready]) + + return useMemo(() => ({ + ...query, + loading: !wallet, + data: wallet ? { wallet } : null + }), [query, wallet]) +} + +export function useWalletProtocolUpsert (wallet, protocol) { + const mutation = getWalletProtocolMutation(protocol) + const [mutate] = useMutation(mutation) + const { encryptConfig } = useEncryptConfig(protocol) + const testSendPayment = useTestSendPayment(protocol) + const logger = useWalletLogger(protocol) + + return useCallback(async (values) => { + logger.info('saving wallet ...') + + if (isTemplate(protocol)) { + values.enabled = true + } + + // skip network tests if we're disabling the wallet + const networkTests = values.enabled + if (networkTests) { + try { + const additionalValues = await testSendPayment(values) + values = { ...values, ...additionalValues } + } catch (err) { + logger.error(err.message) + throw err + } + } + + const encrypted = await encryptConfig(values) + + const variables = encrypted + if (!protocol.send) { + variables.networkTests = networkTests + } + if (isWallet(wallet)) { + variables.walletId = wallet.id + } else { + variables.templateName = wallet.name + } + + let updatedWallet + try { + const { data } = await mutate({ variables }) + logger.ok('wallet saved') + updatedWallet = Object.values(data)[0] + } catch (err) { + logger.error(err.message) + throw err + } + + return updatedWallet + }, [wallet, protocol, logger, testSendPayment, encryptConfig, mutate]) +} + +export function useLightningAddressUpsert () { + // TODO(wallet-v2): parse domain from address input to use correct wallet template + // useWalletProtocolUpsert needs to support passing in the wallet in the callback for that + const wallet = { name: 'LN_ADDR', __typename: 'WalletTemplate' } + const protocol = { name: 'LN_ADDR', send: false, __typename: 'WalletProtocolTemplate' } + return useWalletProtocolUpsert(wallet, protocol) +} + +export function useWalletProtocolRemove (protocol) { + const [mutate] = useMutation(REMOVE_WALLET_PROTOCOL) + const toaster = useToast() + + return useCallback(async () => { + try { + await mutate({ variables: { id: protocol.id } }) + toaster.success('protocol detached') + } catch (err) { + toaster.danger('failed to detach protocol: ' + err.message) + } + }, [protocol?.id, mutate, toaster]) +} + +export function useWalletEncryptionUpdate () { + const wallets = useWallets() + const [mutate] = useMutation(UPDATE_WALLET_ENCRYPTION) + const setKey = useSetKey() + const { encryptConfig } = useEncryptConfig() + + return useCallback(async ({ key, hash }) => { + const encrypted = await Promise.all( + wallets.map(async d => ({ + ...d, + protocols: await Promise.all( + d.protocols.map(p => { + return encryptConfig(p.config, { key, hash, protocol: p }) + })) + })) + ) + + const data = encrypted.map(wallet => ({ + id: wallet.id, + protocols: wallet.protocols.map(protocol => { + const { id, __typename: relationName, ...config } = protocol + const { name, send } = reverseProtocolRelationName(relationName) + return { name, send, config } + }) + })) + + await mutate({ variables: { keyHash: hash, wallets: data } }) + + await setKey({ key, hash }) + }, [wallets, mutate, setKey, encryptConfig]) +} + +export function useWalletReset () { + const [mutate] = useMutation(RESET_WALLETS) + + return useCallback(async ({ newKeyHash }) => { + await mutate({ variables: { newKeyHash } }) + }, [mutate]) +} + +export function useDisablePassphraseExport () { + const [mutate] = useMutation(DISABLE_PASSPHRASE_EXPORT) + + return useCallback(async () => { + await mutate() + }, [mutate]) +} + +export function useSetWalletPriorities () { + const [mutate] = useMutation(SET_WALLET_PRIORITIES) + const toaster = useToast() + + return useCallback(async (wallets) => { + const priorities = wallets.map((wallet, index) => ({ + id: wallet.id, + priority: index + })) + + try { + await mutate({ variables: { priorities } }) + } catch (err) { + console.error('failed to update wallet priorities:', err) + toaster.danger('failed to update wallet priorities') + } + }, [mutate, toaster]) +} + +function getWalletProtocolMutation (protocol) { + switch (protocol.name) { + case 'LNBITS': + return protocol.send ? UPSERT_WALLET_SEND_LNBITS : UPSERT_WALLET_RECEIVE_LNBITS + case 'PHOENIXD': + return protocol.send ? UPSERT_WALLET_SEND_PHOENIXD : UPSERT_WALLET_RECEIVE_PHOENIXD + case 'BLINK': + return protocol.send ? UPSERT_WALLET_SEND_BLINK : UPSERT_WALLET_RECEIVE_BLINK + case 'LN_ADDR': + return protocol.send ? null : UPSERT_WALLET_RECEIVE_LIGHTNING_ADDRESS + case 'NWC': + return protocol.send ? UPSERT_WALLET_SEND_NWC : UPSERT_WALLET_RECEIVE_NWC + case 'CLN_REST': + return protocol.send ? null : UPSERT_WALLET_RECEIVE_CLN_REST + case 'LND_GRPC': + return protocol.send ? null : UPSERT_WALLET_RECEIVE_LNDGRPC + case 'LNC': + return protocol.send ? UPSERT_WALLET_SEND_LNC : null + case 'WEBLN': + return protocol.send ? UPSERT_WALLET_SEND_WEBLN : null + default: + return null + } +} + +function useTestSendPayment (protocol) { + return useCallback(async (values) => { + if (!protocol.send) return + + return await protocolTestSendPayment( + protocol, + values, + { signal: timeoutSignal(WALLET_SEND_PAYMENT_TIMEOUT_MS) } + ) + }, [protocol]) +} + +function useWalletDecryption () { + const { decryptConfig, ready } = useDecryptConfig() + + const decryptWallet = useCallback(async wallet => { + if (!isWallet(wallet)) return wallet + + const protocols = await Promise.all( + wallet.protocols.map( + async protocol => ({ + ...protocol, + config: await decryptConfig(protocol.config) + }) + ) + ) + return { ...wallet, protocols } + }, [decryptConfig]) + + return useMemo(() => ({ decryptWallet, ready }), [decryptWallet, ready]) +} + +function useDecryptConfig () { + const { decrypt, ready } = useDecryption() + + const decryptConfig = useCallback(async (config) => { + return Object.fromEntries( + await Promise.all( + Object.entries(config) + .map( + async ([key, value]) => { + if (!isEncrypted(value)) return [key, value] + + // undo the field aliases we had to use because of https://github.com/graphql/graphql-js/issues/53 + // so we can pretend the GraphQL API returns the fields as they are named in the schema + let renamed = key.replace(/^encrypted/, '') + renamed = renamed.charAt(0).toLowerCase() + renamed.slice(1) + + return [ + renamed, + await decrypt(value) + ] + } + ) + ) + ) + }, [decrypt]) + + return useMemo(() => ({ decryptConfig, ready }), [decryptConfig, ready]) +} + +function isEncrypted (value) { + return value.__typename === 'VaultEntry' +} + +function useEncryptConfig (defaultProtocol, options = {}) { + const { encrypt, ready } = useEncryption(options) + + const encryptConfig = useCallback(async (config, { key: cryptoKey, hash, protocol } = {}) => { + return Object.fromEntries( + await Promise.all( + Object.entries(config) + .map( + async ([fieldKey, value]) => { + if (!isEncryptedField(protocol ?? defaultProtocol, fieldKey)) return [fieldKey, value] + return [ + fieldKey, + await encrypt(value, { key: cryptoKey, hash }) + ] + } + ) + ) + ) + }, [defaultProtocol, encrypt]) + + return useMemo(() => ({ encryptConfig, ready }), [encryptConfig, ready]) +} + +// TODO(wallet-v2): remove migration code +// ============================================================= +// ****** Below is the migration code for WALLET v1 -> v2 ****** +// remove when we can assume migration is complete (if ever) +// ============================================================= + +export function useWalletMigrationMutation () { + const wallets = useWallets() + const loading = useWalletsLoading() + const client = useApolloClient() + const { encryptConfig, ready } = useEncryptConfig() + + // XXX We use a ref for the wallets to avoid duplicate wallets + // Without a ref, the migrate callback would depend on the wallets and thus update every time the migration creates a wallet. + // This update would then cause the useEffect in wallets/client/context/hooks that triggers the migration to run again before the first migration is complete. + const walletsRef = useRef(wallets) + useEffect(() => { + if (!loading) walletsRef.current = wallets + }, [loading]) + + const migrate = useCallback(async ({ name, enabled, ...configV1 }) => { + const protocol = { name, send: true } + + const configV2 = migrateConfig(protocol, configV1) + + const isSameProtocol = (p) => { + const sameName = p.name === protocol.name + const sameSend = p.send === protocol.send + const sameConfig = Object.keys(p.config) + .filter(k => !['__typename', 'id'].includes(k)) + .every(k => p.config[k] === configV2[k]) + return sameName && sameSend && sameConfig + } + + const exists = walletsRef.current.some(w => w.name === name && w.protocols.some(isSameProtocol)) + if (exists) return + + const schema = protocolClientSchema(protocol) + await schema.validate(configV2) + + const encrypted = await encryptConfig(configV2, { protocol }) + + // decide if we create a new wallet (templateName) or use an existing one (walletId) + const templateName = getWalletTemplateName(protocol) + let walletId + const wallet = walletsRef.current.find(w => + w.name === name && !w.protocols.some(p => p.name === protocol.name && p.send) + ) + if (wallet) { + walletId = Number(wallet.id) + } + + await client.mutate({ + mutation: getWalletProtocolMutation(protocol), + variables: { + ...(walletId ? { walletId } : { templateName }), + enabled, + ...encrypted + } + }) + }, [client, encryptConfig]) + + return useMemo(() => ({ migrate, ready: ready && !loading }), [migrate, ready, loading]) +} + +export function useUpdateKeyHash () { + const [mutate] = useMutation(UPDATE_KEY_HASH) + + return useCallback(async (keyHash) => { + await mutate({ variables: { keyHash } }) + }, [mutate]) +} + +function migrateConfig (protocol, config) { + switch (protocol.name) { + case 'LNBITS': + return { + url: config.url, + apiKey: config.adminKey + } + case 'PHOENIXD': + return { + url: config.url, + apiKey: config.primaryPassword + } + case 'BLINK': + return { + url: config.url, + apiKey: config.apiKey, + currency: config.currency + } + case 'LNC': + return { + pairingPhrase: config.pairingPhrase, + localKey: config.localKey, + remoteKey: config.remoteKey, + serverHost: config.serverHost + } + case 'WEBLN': + return {} + case 'NWC': + return { + url: config.nwcUrl + } + default: + return config + } +} + +function getWalletTemplateName (protocol) { + switch (protocol.name) { + case 'LNBITS': + case 'PHOENIXD': + case 'BLINK': + case 'NWC': + return protocol.name + case 'LNC': + return 'LND' + case 'WEBLN': + return 'ALBY' + default: + return null + } +} diff --git a/wallets/client/hooks/wallet.js b/wallets/client/hooks/wallet.js new file mode 100644 index 00000000..95c14c41 --- /dev/null +++ b/wallets/client/hooks/wallet.js @@ -0,0 +1,49 @@ +import { useWallets } from '@/wallets/client/context' +import protocols from '@/wallets/client/protocols' +import { isWallet } from '@/wallets/lib/util' +import { useMemo } from 'react' + +export function useSendProtocols () { + const wallets = useWallets() + return useMemo( + () => wallets + .filter(w => w.send) + .reduce((acc, wallet) => { + return [ + ...acc, + ...wallet.protocols + .filter(p => p.send && p.enabled) + .map(walletProtocol => { + const { sendPayment } = protocols.find(p => p.name === walletProtocol.name) + return { + ...walletProtocol, + sendPayment + } + }) + ] + }, []) + , [wallets]) +} + +export function useHasSendWallet () { + const protocols = useSendProtocols() + return useMemo(() => protocols.length > 0, [protocols]) +} + +export function useWalletSupport (wallet) { + const template = isWallet(wallet) ? wallet.template : wallet + return useMemo(() => ({ receive: template.receive === WalletStatus.OK, send: template.send === WalletStatus.OK }), [template]) +} + +export const WalletStatus = { + OK: 'OK', + ERROR: 'ERROR', + WARNING: 'WARNING', + DISABLED: 'DISABLED' +} + +export function useWalletStatus (wallet) { + if (!isWallet(wallet)) return WalletStatus.DISABLED + + return useMemo(() => ({ send: wallet.send, receive: wallet.receive }), [wallet]) +} diff --git a/wallets/blink/client.js b/wallets/client/protocols/blink.js similarity index 94% rename from wallets/blink/client.js rename to wallets/client/protocols/blink.js index c5a487b8..8bb00bfb 100644 --- a/wallets/blink/client.js +++ b/wallets/client/protocols/blink.js @@ -1,9 +1,13 @@ -import { getScopes, SCOPE_READ, SCOPE_WRITE, getWallet, request } from '@/wallets/blink/common' -export * from '@/wallets/blink' +import { getScopes, SCOPE_READ, SCOPE_WRITE, getWallet, request } from '@/wallets/lib/protocols/blink' -export async function testSendPayment ({ apiKey, currency }, { logger, signal }) { - logger.info('trying to fetch ' + currency + ' wallet') +export const name = 'BLINK' +export async function sendPayment (bolt11, { apiKey, currency }, { signal }) { + const wallet = await getWallet({ apiKey, currency }, { signal }) + return await payInvoice(bolt11, { apiKey, wallet }, { signal }) +} + +export async function testSendPayment ({ apiKey, currency }, { signal }) { const scopes = await getScopes({ apiKey }, { signal }) if (!scopes.includes(SCOPE_READ)) { throw new Error('missing READ scope') @@ -14,13 +18,6 @@ export async function testSendPayment ({ apiKey, currency }, { logger, signal }) currency = currency ? currency.toUpperCase() : 'BTC' await getWallet({ apiKey, currency }, { signal }) - - logger.ok(currency + ' wallet found') -} - -export async function sendPayment (bolt11, { apiKey, currency }, { signal }) { - const wallet = await getWallet({ apiKey, currency }, { signal }) - return await payInvoice(bolt11, { apiKey, wallet }, { signal }) } async function payInvoice (bolt11, { apiKey, wallet }, { signal }) { diff --git a/wallets/client/protocols/index.js b/wallets/client/protocols/index.js new file mode 100644 index 00000000..a7cb149b --- /dev/null +++ b/wallets/client/protocols/index.js @@ -0,0 +1,56 @@ +import * as nwc from './nwc' +import * as lnbits from './lnbits' +import * as phoenixd from './phoenixd' +import * as blink from './blink' +import * as webln from './webln' +import * as lnc from './lnc' + +export * from './util' + +/** + * @typedef {@import('@/wallets/lib/protocols').ProtocolName} ProtocolName + */ + +/** + * @typedef {Object} ClientWalletProtocol + * @property {ProtocolName} name - must match a protocol name in the database + * @property {ProtocolCreateInvoice} createInvoice - create a new invoice + * @property {ProtocolTestCreateInvoice} testCreateInvoice - create a test invoice + */ + +/** + * @callback ProtocolSendPayment + * @param {SendPaymentArgs} args - arguments for the payment + * @param {Object} config - current protocol configuration + * @param {SendPaymentOptions} opts - additional options for the payment + * @returns {Promise} - preimage + */ + +/** + * @typedef {Object} SendPaymentArgs + * @property {number} bolt11 - the bolt11 invoice the wallet should pay + */ + +/** + * @typedef {Object} SendPaymentOptions + * @property {AbortSignal} signal - signal to abort the request + */ + +/** + * @callback ProtocolTestSendPayment + * @param {Object} config - current protocol configuration + * @param {SendPaymentOptions} opts - additional options for the payment + * @returns {Promise} + */ + +/** @typedef {string} Preimage */ + +/** @type {ClientWalletProtocol[]} */ +export default [ + nwc, + lnbits, + phoenixd, + blink, + webln, + lnc +] diff --git a/wallets/lnbits/client.js b/wallets/client/protocols/lnbits.js similarity index 69% rename from wallets/lnbits/client.js rename to wallets/client/protocols/lnbits.js index 915840f5..17f02322 100644 --- a/wallets/lnbits/client.js +++ b/wallets/client/protocols/lnbits.js @@ -1,23 +1,14 @@ import { fetchWithTimeout } from '@/lib/fetch' import { assertContentTypeJson } from '@/lib/url' -export * from '@/wallets/lnbits' +export const name = 'LNBITS' -export async function testSendPayment ({ url, adminKey, invoiceKey }, { signal, logger }) { - logger.info('trying to fetch wallet') - - url = url.replace(/\/+$/, '') - await getWallet({ url, adminKey, invoiceKey }, { signal }) - - logger.ok('wallet found') -} - -export async function sendPayment (bolt11, { url, adminKey }, { signal }) { +export async function sendPayment (bolt11, { url, apiKey }, { signal }) { url = url.replace(/\/+$/, '') - const response = await postPayment(bolt11, { url, adminKey }, { signal }) + const response = await postPayment(bolt11, { url, apiKey }, { signal }) - const checkResponse = await getPayment(response.payment_hash, { url, adminKey }, { signal }) + const checkResponse = await getPayment(response.payment_hash, { url, apiKey }, { signal }) if (!checkResponse.preimage) { throw new Error('No preimage') } @@ -25,13 +16,18 @@ export async function sendPayment (bolt11, { url, adminKey }, { signal }) { return checkResponse.preimage } -async function getWallet ({ url, adminKey, invoiceKey }, { signal }) { +export async function testSendPayment ({ url, apiKey }, { signal }) { + url = url.replace(/\/+$/, '') + await getWallet({ url, apiKey }, { signal }) +} + +async function getWallet ({ url, apiKey }, { signal }) { const path = '/api/v1/wallet' const headers = new Headers() headers.append('Accept', 'application/json') headers.append('Content-Type', 'application/json') - headers.append('X-Api-Key', adminKey || invoiceKey) + headers.append('X-Api-Key', apiKey) const method = 'GET' const res = await fetchWithTimeout(url + path, { method, headers, signal }) @@ -46,13 +42,13 @@ async function getWallet ({ url, adminKey, invoiceKey }, { signal }) { return wallet } -async function postPayment (bolt11, { url, adminKey }, { signal }) { +async function postPayment (bolt11, { url, apiKey }, { signal }) { const path = '/api/v1/payments' const headers = new Headers() headers.append('Accept', 'application/json') headers.append('Content-Type', 'application/json') - headers.append('X-Api-Key', adminKey) + headers.append('X-Api-Key', apiKey) const body = JSON.stringify({ bolt11, out: true }) @@ -69,13 +65,13 @@ async function postPayment (bolt11, { url, adminKey }, { signal }) { return payment } -async function getPayment (paymentHash, { url, adminKey }, { signal }) { +async function getPayment (paymentHash, { url, apiKey }, { signal }) { const path = `/api/v1/payments/${paymentHash}` const headers = new Headers() headers.append('Accept', 'application/json') headers.append('Content-Type', 'application/json') - headers.append('X-Api-Key', adminKey) + headers.append('X-Api-Key', apiKey) const method = 'GET' const res = await fetchWithTimeout(url + path, { method, headers, signal }) diff --git a/wallets/lnc/client.js b/wallets/client/protocols/lnc.js similarity index 99% rename from wallets/lnc/client.js rename to wallets/client/protocols/lnc.js index 6009f0f2..cf4efa93 100644 --- a/wallets/lnc/client.js +++ b/wallets/client/protocols/lnc.js @@ -1,17 +1,10 @@ import { Mutex } from 'async-mutex' -export * from '@/wallets/lnc' + +export const name = 'LNC' const mutex = new Mutex() const serverHost = 'mailbox.terminal.lightning.today:443' -export async function testSendPayment (credentials, { logger }) { - const lnc = await getLNC(credentials, { logger }) - logger?.info('validating permissions ...') - await validateNarrowPerms(lnc) - logger?.info('permissions ok') - return lnc.credentials.credentials -} - export async function sendPayment (bolt11, credentials, { logger }) { return await mutex.runExclusive(async () => { const lnc = await getLNC(credentials, { logger }) @@ -22,6 +15,14 @@ export async function sendPayment (bolt11, credentials, { logger }) { }) } +export async function testSendPayment (credentials, { logger }) { + const lnc = await getLNC(credentials, { logger }) + logger?.info('validating permissions ...') + await validateNarrowPerms(lnc) + logger?.info('permissions ok') + return lnc.credentials.credentials +} + async function disconnectLNC (lnc, { logger } = {}) { try { if (!lnc?.isConnected) return diff --git a/wallets/client/protocols/nwc.js b/wallets/client/protocols/nwc.js new file mode 100644 index 00000000..2197a60e --- /dev/null +++ b/wallets/client/protocols/nwc.js @@ -0,0 +1,15 @@ +import { supportedMethods, nwcTryRun } from '@/wallets/lib/protocols/nwc' + +export const name = 'NWC' + +export async function sendPayment (bolt11, { url }, { signal }) { + const result = await nwcTryRun(nwc => nwc.lnPay({ pr: bolt11 }), { url }, { signal }) + return result.preimage +} + +export async function testSendPayment ({ url }, { signal }) { + const supported = await supportedMethods(url, { signal }) + if (!supported.includes('pay_invoice')) { + throw new Error('pay_invoice not supported') + } +} diff --git a/wallets/phoenixd/client.js b/wallets/client/protocols/phoenixd.js similarity index 74% rename from wallets/phoenixd/client.js rename to wallets/client/protocols/phoenixd.js index db9e438b..2484edb6 100644 --- a/wallets/phoenixd/client.js +++ b/wallets/client/protocols/phoenixd.js @@ -1,22 +1,14 @@ import { fetchWithTimeout } from '@/lib/fetch' import { assertContentTypeJson, assertResponseOk } from '@/lib/url' -export * from '@/wallets/phoenixd' +export const name = 'PHOENIXD' -export async function testSendPayment (config, { logger, signal }) { - // TODO: - // Not sure which endpoint to call to test primary password - // see https://phoenix.acinq.co/server/api - // Maybe just wait until test payments with HODL invoices? - -} - -export async function sendPayment (bolt11, { url, primaryPassword }, { signal }) { +export async function sendPayment (bolt11, { url, apiKey }, { signal }) { // https://phoenix.acinq.co/server/api#pay-bolt11-invoice const path = '/payinvoice' const headers = new Headers() - headers.set('Authorization', 'Basic ' + Buffer.from(':' + primaryPassword).toString('base64')) + headers.set('Authorization', 'Basic ' + Buffer.from(':' + apiKey).toString('base64')) headers.set('Content-type', 'application/x-www-form-urlencoded') const body = new URLSearchParams() @@ -41,3 +33,11 @@ export async function sendPayment (bolt11, { url, primaryPassword }, { signal }) return preimage } + +export async function testSendPayment (config, { signal }) { + // TODO: + // Not sure which endpoint to call to test primary password + // see https://phoenix.acinq.co/server/api + // Maybe just wait until test payments with HODL invoices? + // https://github.com/stackernews/stacker.news/issues/1287 +} diff --git a/wallets/client/protocols/util.js b/wallets/client/protocols/util.js new file mode 100644 index 00000000..4f5d04c4 --- /dev/null +++ b/wallets/client/protocols/util.js @@ -0,0 +1,13 @@ +import protocols from '@/wallets/client/protocols' + +function protocol (name) { + return protocols.find(protocol => protocol.name === name) +} + +export function protocolSendPayment ({ name }, args, config, opts) { + return protocol(name).sendPayment(args, config, opts) +} + +export function protocolTestSendPayment ({ name }, config, opts) { + return protocol(name).testSendPayment(config, opts) +} diff --git a/wallets/client/protocols/webln.js b/wallets/client/protocols/webln.js new file mode 100644 index 00000000..b9c37c42 --- /dev/null +++ b/wallets/client/protocols/webln.js @@ -0,0 +1,33 @@ +import { WalletError } from '@/wallets/client/errors' + +export const name = 'WEBLN' + +export async function sendPayment (bolt11) { + if (typeof window.webln === 'undefined') { + throw new WalletError('lightning browser extension not found') + } + + // this will prompt the user to unlock the wallet if it's locked + try { + await window.webln.enable() + } catch (err) { + throw new WalletError(err.message) + } + + // this will prompt for payment if no budget is set + const response = await window.webln.sendPayment(bolt11) + if (!response) { + // sendPayment returns nothing if WebLN was enabled + // but browser extension that provides WebLN was then disabled + // without reloading the page + throw new WalletError('sendPayment returned no response') + } + + return response.preimage +} + +export async function testSendPayment () { + if (typeof window.webln === 'undefined') { + throw new WalletError('lightning browser extension not found') + } +} diff --git a/wallets/cln/client.js b/wallets/cln/client.js deleted file mode 100644 index 97b542b3..00000000 --- a/wallets/cln/client.js +++ /dev/null @@ -1 +0,0 @@ -export * from '@/wallets/cln' diff --git a/wallets/cln/index.js b/wallets/cln/index.js deleted file mode 100644 index f2c7ab13..00000000 --- a/wallets/cln/index.js +++ /dev/null @@ -1,72 +0,0 @@ -import { decodeRune } from '@/lib/cln' -import { B64_URL_REGEX } from '@/lib/format' -import { string } from '@/lib/yup' - -export const name = 'cln' -export const walletType = 'CLN' -export const walletField = 'walletCLN' - -export const fields = [ - { - name: 'socket', - label: 'rest host and port', - type: 'text', - placeholder: '55.5.555.55:3010', - hint: 'tor or clearnet', - clear: true, - serverOnly: true, - validate: string().socket() - }, - { - name: 'rune', - label: 'invoice only rune', - help: { - text: 'We only accept runes that *only* allow `method=invoice`.\n\n' + - 'Run this if you are on v23.08 to generate one:\n\n' + - '```lightning-cli createrune restrictions=\'["method=invoice"]\'```\n\n' + - 'Or this if you are on v24.11 or later:\n\n' + - '```lightning-cli createrune restrictions=\'[["method=invoice"]]\'```\n\n' + - '[see `createrune` documentation](https://docs.corelightning.org/reference/lightning-createrune#restriction-format)' - }, - type: 'text', - placeholder: 'S34KtUW-6gqS_hD_9cc_PNhfF-NinZyBOCgr1aIrark9NCZtZXRob2Q9aW52b2ljZQ==', - hint: 'must be restricted to method=invoice', - clear: true, - serverOnly: true, - validate: string().matches(B64_URL_REGEX, { message: 'invalid rune' }) - .test({ - name: 'rune', - test: (v, context) => { - const decoded = decodeRune(v) - if (!decoded) return context.createError({ message: 'invalid rune' }) - if (decoded.restrictions.length === 0) { - return context.createError({ message: 'rune must be restricted to method=invoice' }) - } - if (decoded.restrictions.length !== 1 || decoded.restrictions[0].alternatives.length !== 1) { - return context.createError({ message: 'rune must be restricted to method=invoice only' }) - } - if (decoded.restrictions[0].alternatives[0] !== 'method=invoice') { - return context.createError({ message: 'rune must be restricted to method=invoice only' }) - } - return true - } - }) - }, - { - name: 'cert', - label: 'cert', - type: 'text', - placeholder: 'LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNNVENDQWRpZ0F3SUJBZ0lRSHBFdFdrcGJwZHV4RVF2eVBPc3NWVEFLQmdncWhrak9QUVFEQWpBdk1SOHcKSFFZRFZRUUtFeFpzYm1RZ1lYVjBiMmRsYm1WeVlYUmxaQ0JqWlhKME1Rd3dDZ1lEVlFRREV3TmliMkl3SGhjTgpNalF3TVRBM01qQXhORE0wV2hjTk1qVXdNekF6TWpBeE5ETTBXakF2TVI4d0hRWURWUVFLRXhac2JtUWdZWFYwCmIyZGxibVZ5WVhSbFpDQmpaWEowTVF3d0NnWURWUVFERXdOaWIySXdXVEFUQmdjcWhrak9QUUlCQmdncWhrak8KUFFNQkJ3TkNBQVJUS3NMVk5oZnhqb1FLVDlkVVdDbzUzSmQwTnBuL1BtYi9LUE02M1JxbU52dFYvdFk4NjJJZwpSbE41cmNHRnBEajhUeFc2OVhIK0pTcHpjWDdlN3N0Um80SFZNSUhTTUE0R0ExVWREd0VCL3dRRUF3SUNwREFUCkJnTlZIU1VFRERBS0JnZ3JCZ0VGQlFjREFUQVBCZ05WSFJNQkFmOEVCVEFEQVFIL01CMEdBMVVkRGdRV0JCVDAKMnh3V25GeHRUNzI0MWxwZlNoNm9FWi9UMWpCN0JnTlZIUkVFZERCeWdnTmliMktDQ1d4dlkyRnNhRzl6ZElJRApZbTlpZ2d4d2IyeGhjaTF1TVMxaWIyS0NGR2h2YzNRdVpHOWphMlZ5TG1sdWRHVnlibUZzZ2dSMWJtbDRnZ3AxCmJtbDRjR0ZqYTJWMGdnZGlkV1pqYjI1dWh3Ui9BQUFCaHhBQUFBQUFBQUFBQUFBQUFBQUFBQUFCaHdTc0VnQUQKTUFvR0NDcUdTTTQ5QkFNQ0EwY0FNRVFDSUEwUTlkRXdoNXpPRnpwL3hYeHNpemh5SkxNVG5yazU1VWx1NHJPRwo4WW52QWlBVGt4U3p3Y3hZZnFscGx0UlNIbmd0NUJFcDBzcXlHL05nenBzb2pmMGNqQT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K', - optional: 'optional if from [CA](https://en.wikipedia.org/wiki/Certificate_authority) (e.g. voltage)', - hint: 'hex or base64 encoded', - clear: true, - serverOnly: true, - validate: string().hexOrBase64() - } -] - -export const card = { - title: 'CLN', - subtitle: 'receive zaps to your Core Lightning node via [CLNRest](https://docs.corelightning.org/docs/rest)', - image: { src: '/wallets/cln.svg' } -} diff --git a/wallets/common.js b/wallets/common.js deleted file mode 100644 index 4064a807..00000000 --- a/wallets/common.js +++ /dev/null @@ -1,174 +0,0 @@ -import walletDefs from '@/wallets/client' - -export function getWalletByName (name) { - return walletDefs.find(def => def.name === name) -} - -export function getWalletByType (type) { - return walletDefs.find(def => def.walletType === type) -} - -export function getStorageKey (name, userId) { - let storageKey = `wallet:${name}` - - // WebLN has no credentials we need to scope to users - // so we can use the same storage key for all users - if (userId && name !== 'webln') { - storageKey = `${storageKey}:${userId}` - } - - return storageKey -} - -export function walletTag (walletDef) { - return walletDef.shortName || walletDef.name -} - -export function walletPrioritySort (w1, w2) { - // enabled/configured wallets always come before disabled/unconfigured wallets - if ((w1.config?.enabled && !w2.config?.enabled) || (isConfigured(w1) && !isConfigured(w2))) { - return -1 - } else if ((w2.config?.enabled && !w1.config?.enabled) || (isConfigured(w2) && !isConfigured(w1))) { - return 1 - } - - const delta = w1.config?.priority - w2.config?.priority - // delta is NaN if either priority is undefined - if (!Number.isNaN(delta) && delta !== 0) return delta - - // if one wallet has a priority but the other one doesn't, the one with the priority comes first - if (w1.config?.priority !== undefined && w2.config?.priority === undefined) return -1 - if (w1.config?.priority === undefined && w2.config?.priority !== undefined) return 1 - - // both wallets have no priority set, falling back to other methods - - // if both wallets have an id, use that as tie breaker - // since that's the order in which autowithdrawals are attempted - if (w1.config?.id && w2.config?.id) return Number(w1.config.id) - Number(w2.config.id) - - // else we will use the card title as tie breaker - return w1.def.card.title < w2.def.card.title ? -1 : 1 -} - -export function isServerField (f) { - return f.serverOnly || !f.clientOnly -} - -export function isClientField (f) { - return f.clientOnly || !f.serverOnly -} - -function checkFields ({ fields, config }) { - // a wallet is configured if all of its required fields are set - let val = fields.every(f => { - if ((f.optional || f.generated) && !f.requiredWithout) return true - return !!config?.[f.name] - }) - - // however, a wallet is not configured if all fields are optional and none are set - // since that usually means that one of them is required - if (val && fields.length > 0) { - val = !(fields.every(f => f.optional || f.generated) && fields.every(f => !config?.[f.name])) - } - - return val -} - -export function isConfigured ({ def, config }) { - return isSendConfigured({ def, config }) || isReceiveConfigured({ def, config }) -} - -function isSendConfigured ({ def, config }) { - const fields = def.fields.filter(isClientField) - return (fields.length > 0 || def.isAvailable?.()) && checkFields({ fields, config }) -} - -function isReceiveConfigured ({ def, config }) { - const fields = def.fields.filter(isServerField) - return fields.length > 0 && checkFields({ fields, config }) -} - -export function supportsSend ({ def, config }) { - return !!def.sendPayment -} - -export function supportsReceive ({ def, config }) { - return def.fields.some(f => f.serverOnly) -} - -export function canSend ({ def, config }) { - return ( - supportsSend({ def, config }) && - isSendConfigured({ def, config }) && - (def.requiresConfig || config?.enabled) - ) -} - -export function canReceive ({ def, config }) { - return supportsReceive({ def, config }) && isReceiveConfigured({ def, config }) -} - -export function siftConfig (fields, config) { - const sifted = { - clientOnly: {}, - serverOnly: {}, - shared: {}, - serverWithShared: {}, - clientWithShared: {}, - settings: null - } - - for (const [key, value] of Object.entries(config)) { - if (['id'].includes(key)) { - sifted.serverOnly[key] = value - continue - } - - if (['autoWithdrawMaxFeePercent', 'autoWithdrawThreshold', 'autoWithdrawMaxFeeTotal'].includes(key)) { - sifted.serverOnly[key] = Number(value) - sifted.settings = { ...sifted.settings, [key]: Number(value) } - continue - } - - const field = fields.find(({ name }) => name === key) - - if (field) { - if (field.serverOnly) { - sifted.serverOnly[key] = value - } else if (field.clientOnly) { - sifted.clientOnly[key] = value - } else { - sifted.shared[key] = value - } - } else if (['enabled', 'priority'].includes(key)) { - sifted.shared[key] = value - } - } - - sifted.serverWithShared = { ...sifted.shared, ...sifted.serverOnly } - sifted.clientWithShared = { ...sifted.shared, ...sifted.clientOnly } - - return sifted -} - -export async function upsertWalletVariables ({ def, config }, encrypt, append = {}) { - const { serverWithShared, settings, clientOnly } = siftConfig(def.fields, config) - // if we are disconnected from the vault, we leave vaultEntries undefined so we don't - // delete entries from connected devices - let vaultEntries - if (clientOnly && encrypt) { - vaultEntries = [] - for (const [key, value] of Object.entries(clientOnly)) { - if (value) { - vaultEntries.push({ key, ...await encrypt(value) }) - } - } - } - - return { ...serverWithShared, settings, vaultEntries, ...append } -} - -export async function saveWalletLocally (name, config, userId) { - const storageKey = getStorageKey(name, userId) - window.localStorage.setItem(storageKey, JSON.stringify(config)) -} diff --git a/wallets/config.js b/wallets/config.js deleted file mode 100644 index 25f1f2ba..00000000 --- a/wallets/config.js +++ /dev/null @@ -1,154 +0,0 @@ -import { useMe } from '@/components/me' -import useVault from '@/components/vault/use-vault' -import { useCallback } from 'react' -import { canReceive, canSend, getStorageKey, saveWalletLocally, siftConfig, upsertWalletVariables } from './common' -import { gql, useMutation } from '@apollo/client' -import { generateMutation } from './graphql' -import { REMOVE_WALLET } from '@/fragments/wallet' -import { useWalletLogger } from '@/wallets/logger' -import { useWallets } from '.' -import validateWallet from './validate' -import { WALLET_SEND_PAYMENT_TIMEOUT_MS } from '@/lib/constants' -import { timeoutSignal, withTimeout } from '@/lib/time' - -export function useWalletConfigurator (wallet) { - const { me } = useMe() - const { reloadLocalWallets } = useWallets() - const { encrypt, isActive } = useVault() - const logger = useWalletLogger(wallet) - const [upsertWallet] = useMutation(generateMutation(wallet?.def)) - const [removeWallet] = useMutation(REMOVE_WALLET) - const [disableFreebies] = useMutation(gql`mutation { disableFreebies }`) - - const _saveToServer = useCallback(async (serverConfig, clientConfig, validateLightning) => { - const variables = await upsertWalletVariables( - { def: wallet.def, config: { ...serverConfig, ...clientConfig } }, - isActive && encrypt, - { validateLightning }) - await upsertWallet({ variables }) - }, [encrypt, isActive, wallet.def]) - - const _saveToLocal = useCallback(async (newConfig) => { - saveWalletLocally(wallet.def.name, newConfig, me?.id) - reloadLocalWallets() - }, [me?.id, wallet.def.name, reloadLocalWallets]) - - const _validate = useCallback(async (config, validateLightning = true) => { - const { serverWithShared, clientWithShared } = siftConfig(wallet.def.fields, config) - - let clientConfig = clientWithShared - let serverConfig = serverWithShared - - if (canSend({ def: wallet.def, config: clientConfig })) { - try { - let transformedConfig = await validateWallet(wallet.def, clientWithShared, { skipGenerated: true }) - if (transformedConfig) { - clientConfig = Object.assign(clientConfig, transformedConfig) - } - if (wallet.def.testSendPayment && validateLightning) { - transformedConfig = await withTimeout( - wallet.def.testSendPayment(clientConfig, { - logger, - signal: timeoutSignal(WALLET_SEND_PAYMENT_TIMEOUT_MS) - }), - WALLET_SEND_PAYMENT_TIMEOUT_MS - ) - if (transformedConfig) { - clientConfig = Object.assign(clientConfig, transformedConfig) - } - // validate again to ensure generated fields are valid - await validateWallet(wallet.def, clientConfig) - } - } catch (err) { - logger.error(err.message) - throw err - } - } else if (canReceive({ def: wallet.def, config: serverConfig })) { - const transformedConfig = await validateWallet(wallet.def, serverConfig) - if (transformedConfig) { - serverConfig = Object.assign(serverConfig, transformedConfig) - } - } else if (wallet.def.requiresConfig) { - throw new Error('configuration must be able to send or receive') - } - - return { clientConfig, serverConfig } - }, [wallet, logger]) - - const _detachFromServer = useCallback(async () => { - await removeWallet({ variables: { id: wallet.config.id } }) - }, [wallet.config?.id]) - - const _detachFromLocal = useCallback(async () => { - window.localStorage.removeItem(getStorageKey(wallet.def.name, me?.id)) - reloadLocalWallets() - }, [me?.id, wallet.def.name, reloadLocalWallets]) - - const save = useCallback(async (newConfig, validateLightning = true) => { - const { clientWithShared: oldClientConfig } = siftConfig(wallet.def.fields, wallet.config) - const { clientConfig: newClientConfig, serverConfig: newServerConfig } = await _validate(newConfig, validateLightning) - - const oldCanSend = canSend({ def: wallet.def, config: oldClientConfig }) - const newCanSend = canSend({ def: wallet.def, config: newClientConfig }) - - // if vault is active, encrypt and send to server regardless of wallet type - if (isActive) { - await _saveToServer(newServerConfig, newClientConfig, validateLightning) - await _detachFromLocal() - } else { - if (newCanSend) { - await _saveToLocal(newClientConfig) - } else { - // if it previously had a client config, remove it - await _detachFromLocal() - } - if (canReceive({ def: wallet.def, config: newServerConfig })) { - await _saveToServer(newServerConfig, newClientConfig, validateLightning) - } else if (wallet.config.id) { - // we previously had a server config - if (wallet.vaultEntries.length > 0) { - // we previously had a server config with vault entries, save it - await _saveToServer(newServerConfig, newClientConfig, validateLightning) - } else { - // we previously had a server config without vault entries, remove it - await _detachFromServer() - } - } - } - - if (newCanSend) { - disableFreebies().catch(console.error) - if (oldCanSend) { - logger.ok('details for sending updated') - } else { - logger.ok('details for sending saved') - } - if (newConfig.enabled) { - logger.ok('sending enabled') - } else { - logger.info('sending disabled') - } - } else if (oldCanSend) { - logger.info('details for sending deleted') - } - }, [isActive, wallet.def, wallet.config, _saveToServer, _saveToLocal, _validate, - _detachFromLocal, _detachFromServer, disableFreebies]) - - const detach = useCallback(async () => { - if (isActive) { - // if vault is active, detach all wallets from server - await _detachFromServer() - } else { - if (wallet.config.id) { - await _detachFromServer() - } - - // if vault is not active and has a client config, delete from local storage - await _detachFromLocal() - } - - logger.info('details for sending deleted') - }, [logger, isActive, _detachFromServer, _detachFromLocal]) - - return { save, detach } -} diff --git a/wallets/graphql.js b/wallets/graphql.js deleted file mode 100644 index b39b6ebd..00000000 --- a/wallets/graphql.js +++ /dev/null @@ -1,51 +0,0 @@ -import gql from 'graphql-tag' -import { isServerField } from './common' -import { WALLET_FIELDS } from '@/fragments/wallet' - -export function fieldToGqlArg (field) { - let arg = `${field.name}: String` - if (!field.optional) { - arg += '!' - } - return arg -} - -// same as fieldToGqlArg, but makes the field always optional -export function fieldToGqlArgOptional (field) { - return `${field.name}: String` -} - -export function generateResolverName (walletField) { - const capitalized = walletField[0].toUpperCase() + walletField.slice(1) - return `upsert${capitalized}` -} - -export function generateTypeDefName (walletType) { - const PascalCase = walletType.split('_').map(s => s[0].toUpperCase() + s.slice(1).toLowerCase()).join('') - return `Wallet${PascalCase}` -} - -export function generateMutation (wallet) { - const resolverName = generateResolverName(wallet.walletField) - - let headerArgs = '$id: ID, ' - headerArgs += wallet.fields - .filter(isServerField) - .map(f => `$${f.name}: String`) - .join(', ') - headerArgs += ', $enabled: Boolean, $priority: Int, $vaultEntries: [VaultEntryInput!], $settings: AutowithdrawSettings, $validateLightning: Boolean' - - let inputArgs = 'id: $id, ' - inputArgs += wallet.fields - .filter(isServerField) - .map(f => `${f.name}: $${f.name}`).join(', ') - inputArgs += ', enabled: $enabled, priority: $priority, vaultEntries: $vaultEntries, settings: $settings, validateLightning: $validateLightning' - - return gql` - ${WALLET_FIELDS} - mutation ${resolverName}(${headerArgs}) { - ${resolverName}(${inputArgs}) { - ...WalletFields - } - }` -} diff --git a/wallets/image.js b/wallets/image.js deleted file mode 100644 index f0d7a27c..00000000 --- a/wallets/image.js +++ /dev/null @@ -1,14 +0,0 @@ -import useDarkMode from '@/components/dark-mode' - -export function useWalletImage (wallet) { - const [darkMode] = useDarkMode() - - const { title, image } = wallet.def.card - - if (!image) return null - - // wallet.png <-> wallet-dark.png - const src = darkMode ? image?.src.replace(/\.([a-z]{3})$/, '-dark.$1') : image?.src - - return { ...image, alt: title, src } -} diff --git a/wallets/index.js b/wallets/index.js deleted file mode 100644 index 167a3428..00000000 --- a/wallets/index.js +++ /dev/null @@ -1,317 +0,0 @@ -import { useMe } from '@/components/me' -import { FAILED_INVOICES, SET_WALLET_PRIORITY, WALLETS } from '@/fragments/wallet' -import { NORMAL_POLL_INTERVAL, SSR } from '@/lib/constants' -import { useApolloClient, useLazyQuery, useMutation, useQuery } from '@apollo/client' -import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' -import { getStorageKey, getWalletByType, walletPrioritySort, canSend, isConfigured, upsertWalletVariables, siftConfig, saveWalletLocally } from './common' -import useVault from '@/components/vault/use-vault' -import walletDefs from '@/wallets/client' -import { generateMutation } from './graphql' -import { useWalletPayment } from './payment' -import useInvoice from '@/components/use-invoice' -import { WalletConfigurationError } from './errors' - -const WalletsContext = createContext({ - wallets: [] -}) - -function useLocalWallets () { - const { me } = useMe() - const [wallets, setWallets] = useState([]) - - const loadWallets = useCallback(() => { - // form wallets from local storage into a list of { config, def } - const wallets = walletDefs.map(w => { - try { - const storageKey = getStorageKey(w.name, me?.id) - const config = window.localStorage.getItem(storageKey) - return { def: w, config: JSON.parse(config) } - } catch (e) { - return null - } - }).filter(Boolean) - setWallets(wallets) - }, [me?.id, setWallets]) - - const removeWallets = useCallback(() => { - for (const wallet of wallets) { - const storageKey = getStorageKey(wallet.def.name, me?.id) - window.localStorage.removeItem(storageKey) - } - setWallets([]) - }, [wallets, setWallets, me?.id]) - - useEffect(() => { - // listen for changes to any wallet config in local storage - // from any window with the same origin - const handleStorage = (event) => { - if (event.key?.startsWith(getStorageKey(''))) { - loadWallets() - } - } - window.addEventListener('storage', handleStorage) - - loadWallets() - return () => window.removeEventListener('storage', handleStorage) - }, [loadWallets]) - - return { wallets, reloadLocalWallets: loadWallets, removeLocalWallets: removeWallets } -} - -const walletDefsOnly = walletDefs.map(w => ({ def: w, config: {} })) - -export function WalletsProvider ({ children }) { - const { isActive, decrypt } = useVault() - const { me } = useMe() - const { wallets: localWallets, reloadLocalWallets, removeLocalWallets } = useLocalWallets() - const [setWalletPriority] = useMutation(SET_WALLET_PRIORITY) - const [serverWallets, setServerWallets] = useState([]) - const client = useApolloClient() - const [loading, setLoading] = useState(true) - - const { data, refetch } = useQuery(WALLETS, - SSR ? {} : { nextFetchPolicy: 'cache-and-network' }) - - // refetch wallets when the vault key hash changes or wallets are updated - useEffect(() => { - if (me?.privates?.walletsUpdatedAt) { - refetch() - } - }, [me?.privates?.walletsUpdatedAt, me?.privates?.vaultKeyHash, refetch]) - - useEffect(() => { - const loadWallets = async () => { - if (!data?.wallets) return - // form wallets into a list of { config, def } - const wallets = [] - for (const w of data.wallets) { - const def = getWalletByType(w.type) - const { vaultEntries, ...config } = w - if (isActive) { - for (const { key, iv, value } of vaultEntries) { - try { - config[key] = await decrypt({ iv, value }) - } catch (e) { - console.error('error decrypting vault entry', e) - } - } - } - - // the specific wallet config on the server is stored in wallet.wallet - // on the client, it's stored unnested - wallets.push({ config: { ...config, ...w.wallet }, def, vaultEntries }) - } - - setServerWallets(wallets) - setLoading(false) - } - loadWallets() - }, [data?.wallets, decrypt, isActive]) - - // merge wallets on name like: { ...unconfigured, ...localConfig, ...serverConfig } - const wallets = useMemo(() => { - const merged = {} - for (const wallet of [...walletDefsOnly, ...localWallets, ...serverWallets]) { - merged[wallet.def.name] = { - def: { - ...wallet.def, - requiresConfig: wallet.def.fields.length > 0 - }, - config: { - ...merged[wallet.def.name]?.config, - ...Object.fromEntries( - Object.entries(wallet.config ?? {}).map(([key, value]) => [ - key, - value ?? merged[wallet.def.name]?.config?.[key] - ]) - ) - }, - vaultEntries: wallet.vaultEntries - } - } - - // sort by priority - return Object.values(merged).sort(walletPrioritySort) - }, [serverWallets, localWallets]) - - const settings = useMemo(() => { - return { - autoWithdrawMaxFeePercent: me?.privates?.autoWithdrawMaxFeePercent, - autoWithdrawThreshold: me?.privates?.autoWithdrawThreshold, - autoWithdrawMaxFeeTotal: me?.privates?.autoWithdrawMaxFeeTotal - } - }, [me?.privates?.autoWithdrawMaxFeePercent, me?.privates?.autoWithdrawThreshold, me?.privates?.autoWithdrawMaxFeeTotal]) - - // whenever the vault key is set, and we have local wallets, - // we'll send any merged local wallets to the server, and delete them from local storage - const syncLocalWallets = useCallback(async encrypt => { - const walletsToSync = wallets.filter(w => - // only sync wallets that have a local config - localWallets.some(localWallet => localWallet.def.name === w.def.name && !!localWallet.config) - ) - if (encrypt && walletsToSync.length > 0) { - for (const wallet of walletsToSync) { - const mutation = generateMutation(wallet.def) - const append = {} - // if the wallet has server-only fields set, add the settings to the mutation variables - if (wallet.def.fields.some(f => f.serverOnly && wallet.config[f.name])) { - append.settings = settings - } - const variables = await upsertWalletVariables(wallet, encrypt, append) - await client.mutate({ mutation, variables }) - } - removeLocalWallets() - } - }, [wallets, localWallets, removeLocalWallets, settings]) - - const unsyncLocalWallets = useCallback(() => { - for (const wallet of wallets) { - const { clientWithShared } = siftConfig(wallet.def.fields, wallet.config) - if (canSend({ def: wallet.def, config: clientWithShared })) { - saveWalletLocally(wallet.def.name, clientWithShared, me?.id) - } - } - reloadLocalWallets() - }, [wallets, me?.id, reloadLocalWallets]) - - const setPriorities = useCallback(async (priorities) => { - for (const { wallet, priority } of priorities) { - if (!isConfigured(wallet)) { - throw new Error(`cannot set priority for unconfigured wallet: ${wallet.def.name}`) - } - - if (wallet.config?.id) { - // set priority on server if it has an id - await setWalletPriority({ variables: { id: wallet.config.id, priority } }) - } else { - const storageKey = getStorageKey(wallet.def.name, me?.id) - const config = window.localStorage.getItem(storageKey) - const newConfig = { ...JSON.parse(config), priority } - window.localStorage.setItem(storageKey, JSON.stringify(newConfig)) - } - } - // reload local wallets if any priorities were set - if (priorities.length > 0) { - reloadLocalWallets() - } - }, [setWalletPriority, me?.id, reloadLocalWallets]) - - // provides priority sorted wallets to children, a function to reload local wallets, - // and a function to set priorities - const value = useMemo(() => ({ - wallets, - loading, - reloadLocalWallets, - setPriorities, - onVaultKeySet: syncLocalWallets, - beforeDisconnectVault: unsyncLocalWallets, - removeLocalWallets - }), [wallets, loading, reloadLocalWallets, setPriorities, syncLocalWallets, unsyncLocalWallets, removeLocalWallets]) - return ( - - - {children} - - - ) -} - -export function useWallets () { - return useContext(WalletsContext) -} - -export function useWallet (name) { - const { wallets } = useWallets() - return wallets.find(w => w.def.name === name) -} - -export function useConfiguredWallets () { - const { wallets, loading } = useWallets() - return useMemo(() => ({ - wallets: wallets.filter(w => isConfigured(w)), - loading - }), [wallets, loading]) -} - -export function useSendWallets () { - const { wallets } = useWallets() - // return all enabled wallets that are available and can send - return useMemo(() => wallets - .filter(w => !w.def.isAvailable || w.def.isAvailable()) - .filter(w => w.config?.enabled && canSend(w)), [wallets]) -} - -function RetryHandler ({ children }) { - const wallets = useSendWallets() - const waitForWalletPayment = useWalletPayment() - const invoiceHelper = useInvoice() - const [getFailedInvoices] = useLazyQuery(FAILED_INVOICES, { fetchPolicy: 'network-only', nextFetchPolicy: 'network-only' }) - const { me } = useMe() - - const retry = useCallback(async (invoice) => { - const newInvoice = await invoiceHelper.retry({ ...invoice, newAttempt: true }) - - try { - await waitForWalletPayment(newInvoice) - } catch (err) { - if (err instanceof WalletConfigurationError) { - // consume attempt by canceling invoice - await invoiceHelper.cancel(newInvoice) - } - throw err - } - }, [invoiceHelper, waitForWalletPayment]) - - useEffect(() => { - // we always retry failed invoices, even if the user has no wallets on any client - // to make sure that failed payments will always show up in notifications eventually - - if (!me) return - - const retryPoll = async () => { - let failedInvoices - try { - const { data, error } = await getFailedInvoices() - if (error) throw error - failedInvoices = data.failedInvoices - } catch (err) { - console.error('failed to fetch invoices to retry:', err) - return - } - - for (const inv of failedInvoices) { - try { - await retry(inv) - } catch (err) { - // some retries are expected to fail since only one client at a time is allowed to retry - // these should show up as 'invoice not found' errors - console.error('retry failed:', err) - } - } - } - - let timeout, stopped - const queuePoll = () => { - timeout = setTimeout(async () => { - try { - await retryPoll() - } catch (err) { - // every error should already be handled in retryPoll - // but this catch is a safety net to not trigger an unhandled promise rejection - console.error('retry poll failed:', err) - } - if (!stopped) queuePoll() - }, NORMAL_POLL_INTERVAL) - } - - const stopPolling = () => { - stopped = true - clearTimeout(timeout) - } - - queuePoll() - return stopPolling - }, [me?.id, wallets, getFailedInvoices, retry]) - - return children -} diff --git a/wallets/indicator.js b/wallets/indicator.js deleted file mode 100644 index a4e7365f..00000000 --- a/wallets/indicator.js +++ /dev/null @@ -1,6 +0,0 @@ -import { useConfiguredWallets } from '@/wallets' - -export function useWalletIndicator () { - const { wallets, loading } = useConfiguredWallets() - return !loading && wallets.length === 0 -} diff --git a/wallets/lib/protocols/blink.js b/wallets/lib/protocols/blink.js new file mode 100644 index 00000000..5d25a661 --- /dev/null +++ b/wallets/lib/protocols/blink.js @@ -0,0 +1,137 @@ +import { string } from 'yup' +import { fetchWithTimeout } from '@/lib/fetch' +import { assertContentTypeJson, assertResponseOk } from '@/lib/url' + +// Blink +// http://blink.sv/ + +export const galoyBlinkUrl = 'https://api.blink.sv/graphql' +export const galoyBlinkDashboardUrl = 'https://dashboard.blink.sv/' + +export const SCOPE_READ = 'READ' +export const SCOPE_WRITE = 'WRITE' +export const SCOPE_RECEIVE = 'RECEIVE' + +const blinkApiKeyValidator = string().matches(/^blink_[A-Za-z0-9]+$/, 'must match pattern blink_A-Za-z0-9') +const blinkCurrencyValidator = string().oneOf(['BTC', 'USD']) + +export default [ + { + name: 'BLINK', + displayName: 'API', + send: true, + fields: [ + { + name: 'apiKey', + type: 'password', + label: 'api key', + placeholder: 'blink_...', + help: [ + `Generate an API key in your [Blink Dashboard](${galoyBlinkDashboardUrl}) with the following scopes:`, + '- READ', + '- WRITE' + ], + validate: blinkApiKeyValidator, + required: true, + encrypt: true + }, + { + name: 'currency', + label: 'currency', + type: 'text', + placeholder: 'BTC or USD', + required: true, + validate: blinkCurrencyValidator, + encrypt: true + } + ], + relationName: 'walletSendBlink' + }, + { + name: 'BLINK', + displayName: 'API', + send: false, + fields: [ + { + name: 'apiKey', + type: 'password', + label: 'api key', + placeholder: 'blink_...', + help: [ + `Generate an API key in your [Blink Dashboard](${galoyBlinkDashboardUrl}) with the following scopes:`, + '- READ', + '- RECEIVE' + ], + validate: blinkApiKeyValidator, + required: true + }, + { + name: 'currency', + label: 'currency', + type: 'text', + placeholder: 'BTC or USD', + required: true, + validate: blinkCurrencyValidator + } + ], + relationName: 'walletRecvBlink' + } +] + +export async function getWallet ({ apiKey, currency }, { signal }) { + const out = await request({ + apiKey, + query: ` + query me { + me { + defaultAccount { + wallets { + id + walletCurrency + } + } + } + }` + }, { signal }) + + const wallets = out.data.me.defaultAccount.wallets + for (const wallet of wallets) { + if (wallet.walletCurrency === currency) { + return wallet + } + } + + throw new Error(`wallet ${currency} not found`) +} + +export async function request ({ apiKey, query, variables = {} }, { signal }) { + const method = 'POST' + const res = await fetchWithTimeout(galoyBlinkUrl, { + method, + headers: { + 'Content-Type': 'application/json', + 'X-API-KEY': apiKey + }, + body: JSON.stringify({ query, variables }), + signal + }) + + assertResponseOk(res, { method }) + assertContentTypeJson(res, { method }) + + return res.json() +} + +export async function getScopes ({ apiKey }, { signal }) { + const out = await request({ + apiKey, + query: ` + query scopes { + authorization { + scopes + } + }` + }, { signal }) + const scopes = out?.data?.authorization?.scopes + return scopes || [] +} diff --git a/wallets/lib/protocols/clnRest.js b/wallets/lib/protocols/clnRest.js new file mode 100644 index 00000000..6e387765 --- /dev/null +++ b/wallets/lib/protocols/clnRest.js @@ -0,0 +1,51 @@ +import { certValidator, runeValidator, socketValidator } from '@/wallets/lib//validate' + +// Core Lightning REST API +// https://docs.corelightning.org/docs/rest + +export default { + name: 'CLN_REST', + displayName: 'CLNRest', + send: false, + fields: [ + { + name: 'socket', + label: 'rest host and port', + type: 'text', + placeholder: '55.5.555.55:3010', + hint: 'tor or clearnet', + required: true, + validate: socketValidator() + }, + { + name: 'rune', + label: 'invoice only rune', + type: 'password', + help: [ + 'We only accept runes that *only* allow `method=invoice`.', + 'Run this if you are on v23.08 to generate one:', + '```lightning-cli createrune restrictions=\'["method=invoice"]\'```', + 'Or this if you are on v24.11 or later:', + '```lightning-cli createrune restrictions=\'[["method=invoice"]]\'```', + '[see `createrune` documentation](https://docs.corelightning.org/reference/lightning-createrune#restriction-format)' + ], + placeholder: 'S34KtUW-6gqS_hD_9cc_PNhfF-NinZyBOCgr1aIrark9NCZtZXRob2Q9aW52b2ljZQ==', + validate: runeValidator({ method: 'invoice' }), + required: true, + hint: 'must be restricted to method=invoice' + }, + { + name: 'cert', + label: 'certificate', + type: 'password', + placeholder: 'LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNNVENDQWRpZ0F3SUJBZ0lRSHBFdFdrcGJwZHV4RVF2eVBPc3NWVEFLQmdncWhrak9QUVFEQWpBdk1SOHcKSFFZRFZRUUtFeFpzYm1RZ1lYVjBiMmRsYm1WeVlYUmxaQ0JqWlhKME1Rd3dDZ1lEVlFRREV3TmliMkl3SGhjTgpNalF3TVRBM01qQXhORE0wV2hjTk1qVXdNekF6TWpBeE5ETTBXakF2TVI4d0hRWURWUVFLRXhac2JtUWdZWFYwCmIyZGxibVZ5WVhSbFpDQmpaWEowTVF3d0NnWURWUVFERXdOaWIySXdXVEFUQmdjcWhrak9QUUlCQmdncWhrak8KUFFNQkJ3TkNBQVJUS3NMVk5oZnhqb1FLVDlkVVdDbzUzSmQwTnBuL1BtYi9LUE02M1JxbU52dFYvdFk4NjJJZwpSbE41cmNHRnBEajhUeFc2OVhIK0pTcHpjWDdlN3N0Um80SFZNSUhTTUE0R0ExVWREd0VCL3dRRUF3SUNwREFUCkJnTlZIU1VFRERBS0JnZ3JCZ0VGQlFjREFUQVBCZ05WSFJNQkFmOEVCVEFEQVFIL01CMEdBMVVkRGdRV0JCVDAKMnh3V25GeHRUNzI0MWxwZlNoNm9FWi9UMWpCN0JnTlZIUkVFZERCeWdnTmliMktDQ1d4dlkyRnNhRzl6ZElJRApZbTlpZ2d4d2IyeGhjaTF1TVMxaWIyS0NGR2h2YzNRdVpHOWphMlZ5TG1sdWRHVnlibUZzZ2dSMWJtbDRnZ3AxCmJtbDRjR0ZqYTJWMGdnZGlkV1pqYjI1dWh3Ui9BQUFCaHhBQUFBQUFBQUFBQUFBQUFBQUFBQUFCaHdTc0VnQUQKTUFvR0NDcUdTTTQ5QkFNQ0EwY0FNRVFDSUEwUTlkRXdoNXpPRnpwL3hYeHNpemh5SkxNVG5yazU1VWx1NHJPRwo4WW52QWlBVGt4U3p3Y3hZZnFscGx0UlNIbmd0NUJFcDBzcXlHL05nenBzb2pmMGNqQT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K', + hint: [ + 'optional if from [CA](https://en.wikipedia.org/wiki/Certificate_authority) (e.g. voltage)', + 'hex or base64 encoded' + ], + validate: certValidator(), + required: false + } + ], + relationName: 'walletRecvCLNRest' +} diff --git a/wallets/cln/ATTACH.md b/wallets/lib/protocols/docs/cln.md similarity index 93% rename from wallets/cln/ATTACH.md rename to wallets/lib/protocols/docs/cln.md index 43f797a8..f496eb69 100644 --- a/wallets/cln/ATTACH.md +++ b/wallets/lib/protocols/docs/cln.md @@ -1,4 +1,4 @@ -For testing cln as an attached receiving wallet, you'll need a rune and the cert. +To attach CLNRest as an receiving wallet protocol, you'll need a rune and the cert. # host and port diff --git a/wallets/lightning-address/ATTACH.md b/wallets/lib/protocols/docs/lnAddr.md similarity index 100% rename from wallets/lightning-address/ATTACH.md rename to wallets/lib/protocols/docs/lnAddr.md diff --git a/wallets/lnbits/ATTACH.md b/wallets/lib/protocols/docs/lnbits.md similarity index 100% rename from wallets/lnbits/ATTACH.md rename to wallets/lib/protocols/docs/lnbits.md diff --git a/wallets/lnc/ATTACH.md b/wallets/lib/protocols/docs/lnc.md similarity index 70% rename from wallets/lnc/ATTACH.md rename to wallets/lib/protocols/docs/lnc.md index 6118253c..f6a1d348 100644 --- a/wallets/lnc/ATTACH.md +++ b/wallets/lib/protocols/docs/lnc.md @@ -1,4 +1,4 @@ -For testing litd as an attached receiving wallet, you'll need a pairing phrase: +For testing Lightning Node Connect via litd as a sending wallet protocol, you'll need a pairing phrase: This can be done one of two ways: @@ -17,6 +17,12 @@ $ sndev cli litd sessions add --type custom --label --account_id ```', + '```litcli sessions add --type custom --label --account_id --uri /lnrpc.Lightning/SendPaymentSync```', + 'Grab the `pairing_secret_mnemonic` from the output and paste it here.' + ], + validate: bip39Validator(), + required: true, + encrypt: true, + editable: false + }, + { + name: 'serverHost', + encrypt: true + }, + { + name: 'localKey', + encrypt: true + }, + { + name: 'remoteKey', + encrypt: true + } + ], + relationName: 'walletSendLNC' +} diff --git a/wallets/lib/protocols/lndGrpc.js b/wallets/lib/protocols/lndGrpc.js new file mode 100644 index 00000000..20bd97a9 --- /dev/null +++ b/wallets/lib/protocols/lndGrpc.js @@ -0,0 +1,49 @@ +import { certValidator, invoiceMacaroonValidator, socketValidator } from '@/wallets/lib/validate' + +// LND gRPC API + +export default { + name: 'LND_GRPC', + displayName: 'gRPC', + send: false, + fields: [ + { + name: 'socket', + label: 'grpc host and port', + placeholder: '55.5.555.55:10001', + hint: 'tor or clearnet', + type: 'text', + validate: socketValidator(), + required: true + }, + { + name: 'macaroon', + label: 'invoice macaroon', + placeholder: 'AgEDbG5kAlgDChCn7YgfWX7uTkQQgXZ2uahNEgEwGhYKB2FkZHJlc3MSBHJlYWQSBXdyaXRlGhcKCGludm9pY2VzEgRyZWFkEgV3cml0ZRoPCgdvbmNoYWluEgRyZWFkAAAGIJkMBrrDV0npU90JV0TGNJPrqUD8m2QYoTDjolaL6eBs', + hint: 'hex or base64 encoded', + help: { + label: 'privacy tip', + text: [ + 'We accept a prebaked ***invoice.macaroon*** for your convenience. To gain better privacy, generate a new macaroon as follows:', + '```lncli bakemacaroon invoices:write invoices:read```' + ] + }, + type: 'password', + validate: invoiceMacaroonValidator(), + required: true + }, + { + name: 'cert', + label: 'certificate', + placeholder: 'LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNNVENDQWRpZ0F3SUJBZ0lRSHBFdFdrcGJwZHV4RVF2eVBPc3NWVEFLQmdncWhrak9QUVFEQWpBdk1SOHcKSFFZRFZRUUtFeFpzYm1RZ1lYVjBiMmRsYm1WeVlYUmxaQ0JqWlhKME1Rd3dDZ1lEVlFRREV3TmliMkl3SGhjTgpNalF3TVRBM01qQXhORE0wV2hjTk1qVXdNekF6TWpBeE5ETTBXakF2TVI4d0hRWURWUVFLRXhac2JtUWdZWFYwCmIyZGxibVZ5WVhSbFpDQmpaWEowTVF3d0NnWURWUVFERXdOaWIySXdXVEFUQmdjcWhrak9QUUlCQmdncWhrak8KUFFNQkJ3TkNBQVJUS3NMVk5oZnhqb1FLVDlkVVdDbzUzSmQwTnBuL1BtYi9LUE02M1JxbU52dFYvdFk4NjJJZwpSbE41cmNHRnBEajhUeFc2OVhIK0pTcHpjWDdlN3N0Um80SFZNSUhTTUE0R0ExVWREd0VCL3dRRUF3SUNwREFUCkJnTlZIU1VFRERBS0JnZ3JCZ0VGQlFjREFUQVBCZ05WSFJNQkFmOEVCVEFEQVFIL01CMEdBMVVkRGdRV0JCVDAKMnh3V25GeHRUNzI0MWxwZlNoNm9FWi9UMWpCN0JnTlZIUkVFZERCeWdnTmliMktDQ1d4dlkyRnNhRzl6ZElJRApZbTlpZ2d4d2IyeGhjaTF1TVMxaWIyS0NGR2h2YzNRdVpHOWphMlZ5TG1sdWRHVnlibUZzZ2dSMWJtbDRnZ3AxCmJtbDRjR0ZqYTJWMGdnZGlkV1pqYjI1dWh3Ui9BQUFCaHhBQUFBQUFBQUFBQUFBQUFBQUFBQUFCaHdTc0VnQUQKTUFvR0NDcUdTTTQ5QkFNQ0EwY0FNRVFDSUEwUTlkRXdoNXpPRnpwL3hYeHNpemh5SkxNVG5yazU1VWx1NHJPRwo4WW52QWlBVGt4U3p3Y3hZZnFscGx0UlNIbmd0NUJFcDBzcXlHL05nenBzb2pmMGNqQT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K', + hint: [ + 'optional if from [CA](https://en.wikipedia.org/wiki/Certificate_authority) (e.g. voltage)', + 'hex or base64 encoded' + ], + type: 'password', + validate: certValidator(), + required: false + } + ], + relationName: 'walletRecvLNDGRPC' +} diff --git a/wallets/lib/protocols/nwc.js b/wallets/lib/protocols/nwc.js new file mode 100644 index 00000000..12a2a72f --- /dev/null +++ b/wallets/lib/protocols/nwc.js @@ -0,0 +1,71 @@ +import Nostr from '@/lib/nostr' +import { NDKNWCWallet } from '@nostr-dev-kit/ndk-wallet' +import { nwcUrlValidator, parseNwcUrl } from '@/wallets/lib/validate' + +// Nostr Wallet Connect (NIP-47) +// https://github.com/nostr-protocol/nips/blob/master/47.md + +export default [ + { + name: 'NWC', + send: true, + displayName: 'Nostr Wallet Connect', + fields: [ + { + name: 'url', + label: 'url', + placeholder: 'nostr+walletconnect://', + type: 'password', + required: true, + validate: nwcUrlValidator(), + encrypt: true + } + ], + relationName: 'walletSendNWC' + }, + { + name: 'NWC', + send: false, + displayName: 'Nostr Wallet Connect', + fields: [ + { + name: 'url', + label: 'url', + placeholder: 'nostr+walletconnect://', + type: 'text', + required: true, + validate: nwcUrlValidator() + } + ], + relationName: 'walletRecvNWC' + } +] + +export async function nwcTryRun (fun, { url }, { signal }) { + const nostr = new Nostr() + try { + const nwc = await getNwc(nostr, url, { signal }) + return await fun(nwc) + } catch (e) { + if (e.error) throw new Error(e.error.message || e.error.code) + throw e + } finally { + nostr.close() + } +} + +export async function getNwc (nostr, url, { signal }) { + const ndk = nostr.ndk + const { walletPubkey, secret, relayUrls } = parseNwcUrl(url) + const nwc = new NDKNWCWallet(ndk, { + pubkey: walletPubkey, + relayUrls, + secret + }) + return nwc +} + +export async function supportedMethods (url, { signal }) { + const result = await nwcTryRun(nwc => nwc.getInfo(), { url }, { signal }) + return result.methods +} diff --git a/wallets/lib/protocols/phoenixd.js b/wallets/lib/protocols/phoenixd.js new file mode 100644 index 00000000..4355c910 --- /dev/null +++ b/wallets/lib/protocols/phoenixd.js @@ -0,0 +1,62 @@ +import { hexValidator, urlValidator } from '@/wallets/lib/validate' + +// Phoenixd +// https://phoenix.acinq.co/server + +export default [ + { + name: 'PHOENIXD', + displayName: 'API', + send: true, + fields: [ + { + name: 'url', + type: 'text', + label: 'url', + validate: urlValidator('clearnet'), + required: true + }, + { + name: 'apiKey', + type: 'password', + label: 'api key', + help: [ + 'The primary password can be found as `http-password` in your phoenixd configuration file.', + 'The default location is ~/.phoenix/phoenix.conf.', + 'Read the [official documentation](https://phoenix.acinq.co/server/api#security) for more details.' + ], + validate: hexValidator(64), + required: true, + encrypt: true + } + ], + relationName: 'walletSendPhoenixd' + }, + { + name: 'PHOENIXD', + displayName: 'API', + send: false, + fields: [ + { + name: 'url', + type: 'text', + label: 'url', + validate: urlValidator('clearnet'), + required: true + }, + { + name: 'apiKey', + type: 'password', + label: 'api key', + help: [ + 'The secondary password can be found as `http-password-limited-access` in your phoenixd configuration file.', + 'The default location is ~/.phoenix/phoenix.conf.', + 'Read the [official documentation](https://phoenix.acinq.co/server/api#security) for more details.' + ], + validate: hexValidator(64), + required: true + } + ], + relationName: 'walletRecvPhoenixd' + } +] diff --git a/wallets/lib/protocols/webln.js b/wallets/lib/protocols/webln.js new file mode 100644 index 00000000..75d8d3c8 --- /dev/null +++ b/wallets/lib/protocols/webln.js @@ -0,0 +1,38 @@ +import { useEffect } from 'react' + +// WebLN +// https://webln.guide/ + +export default { + name: 'WEBLN', + displayName: 'WebLN', + send: true, + fields: [], + relationName: 'walletSendWebLN', + isAvailable: () => 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 +} diff --git a/wallets/lib/util.js b/wallets/lib/util.js new file mode 100644 index 00000000..fbe12c44 --- /dev/null +++ b/wallets/lib/util.js @@ -0,0 +1,116 @@ +import * as yup from 'yup' +import wallets from '@/wallets/lib/wallets.json' +import protocols from '@/wallets/lib/protocols' + +function walletJson (name) { + return wallets.find(wallet => wallet.name === name) +} + +export function walletDisplayName (name) { + return walletJson(name)?.displayName || titleCase(name) +} + +export function walletImage (name) { + return walletJson(name)?.image +} + +export function walletLud16Domain (name) { + const url = walletJson(name)?.url + if (!url) return undefined + + return typeof url === 'string' ? new URL(url).hostname : url.lud16Domain +} + +function protocol ({ name, send }) { + return protocols.find(protocol => protocol.name === name && protocol.send === send) +} + +export function protocolDisplayName ({ name, send }) { + return protocol({ name, send })?.displayName || titleCase(name) +} + +export function protocolRelationName ({ name, send }) { + return protocol({ name, send })?.relationName +} + +export function reverseProtocolRelationName (relationName) { + return protocols.find(protocol => protocol.relationName.toLowerCase() === relationName.toLowerCase()) +} + +export function protocolClientSchema ({ name, send }) { + const fields = protocolFields({ name, send }) + const schema = yup.object(fields.reduce((acc, field) => + ({ + ...acc, + [field.name]: field.required ? field.validate.required('required') : field.validate + }), {})) + return schema +} + +export function protocolServerSchema ({ name, send }, { keyHash, ignoreKeyHash }) { + const fields = protocolFields({ name, send }) + const schema = yup.object(fields.reduce((acc, field) => { + if (field.encrypt) { + const ivSchema = yup.string().hex().length(24) + const valueSchema = yup.string().hex() + return { + ...acc, + [field.name]: yup.object({ + iv: field.required ? ivSchema.required('required') : ivSchema, + value: field.required ? valueSchema.required('required') : valueSchema, + ...(!ignoreKeyHash ? { keyHash: yup.string().required('required').equals([keyHash], `must be ${keyHash}`) } : {}) + }) + } + } + + return { + ...acc, + [field.name]: field.required ? field.validate.required('required') : field.validate + } + }, {})) + return schema +} + +export function protocolMutationName ({ name, send }) { + const relationName = protocolRelationName({ name, send }) + return `upsert${relationName.charAt(0).toUpperCase() + relationName.slice(1)}` +} + +export function protocolFields ({ name, send }) { + return protocol({ name, send })?.fields || [] +} + +export function protocolAvailable ({ name, send }) { + const { isAvailable } = protocol({ name, send }) + + if (typeof isAvailable === 'function') { + return isAvailable() + } + + return true +} + +export function isEncryptedField (protocol, key) { + const fields = protocolFields(protocol) + return fields.find(field => field.name === key && field.encrypt) +} + +export function urlify (name) { + return name.toLowerCase().replace(/_/g, '-') +} + +export function unurlify (urlName) { + return urlName.toUpperCase().replace(/-/g, '_') +} + +function titleCase (name) { + return name.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join(' ') +} + +export function isWallet (wallet) { + return !isTemplate(wallet) +} + +export function isTemplate (obj) { + return obj.__typename.endsWith('Template') +} diff --git a/wallets/lib/validate.js b/wallets/lib/validate.js new file mode 100644 index 00000000..4dc4e630 --- /dev/null +++ b/wallets/lib/validate.js @@ -0,0 +1,175 @@ +import bip39Words from '@/lib/bip39-words' +import { decodeRune } from '@/lib/cln' +import { B64_URL_REGEX } from '@/lib/format' +import { isInvoicableMacaroon, isInvoiceMacaroon } from '@/lib/macaroon' +import { NOSTR_PUBKEY_HEX } from '@/lib/nostr' +import { TOR_REGEXP } from '@/lib/url' +import { lightningAddressValidator } from '@/lib/validate' +import { string, array } from 'yup' + +export const externalLightningAddressValidator = lightningAddressValidator + .test({ + name: 'address', + test: addr => !addr.toLowerCase().endsWith('@stacker.news'), + message: 'lightning address must be external' + }) + +export const nwcUrlValidator = () => + string() + .url() + .test({ + test: (url, context) => { + if (!url) return true + + // run validation in sequence to control order of errors + // inspired by https://github.com/jquense/yup/issues/851#issuecomment-1049705180 + try { + string().matches(/^nostr\+?walletconnect:\/\//, { message: 'must start with nostr+walletconnect://' }).validateSync(url) + let relayUrls, walletPubkey, secret + try { + ({ relayUrls, walletPubkey, secret } = parseNwcUrl(url)) + } catch { + // invalid URL error. handle as if pubkey validation failed to not confuse user. + throw new Error('pubkey must be 64 hex chars') + } + string().required('pubkey required').trim().matches(NOSTR_PUBKEY_HEX, 'pubkey must be 64 hex chars').validateSync(walletPubkey) + array().of( + string().required('relay url required').trim().wss('relay must use wss://') + ).min(1, 'at least one relay required').validateSync(relayUrls) + string().required('secret required').trim().matches(/^[0-9a-fA-F]{64}$/, 'secret must be 64 hex chars').validateSync(secret) + } catch (err) { + return context.createError({ message: err.message }) + } + return true + } + }) + +export function parseNwcUrl (walletConnectUrl) { + if (!walletConnectUrl) return {} + + walletConnectUrl = walletConnectUrl + .replace('nostrwalletconnect://', 'http://') + .replace('nostr+walletconnect://', 'http://') // makes it possible to parse with URL in the different environments (browser/node/...) + + // XXX There is a bug in parsing since we use the URL constructor for parsing: + // A wallet pubkey matching /^[0-9a-fA-F]{64}$/ might not be a valid hostname. + // Example: 11111111111 (10 1's) is a valid hostname (gets parsed as IPv4) but 111111111111 (11 1's) is not. + // See https://stackoverflow.com/questions/56804936/how-does-only-numbers-in-url-resolve-to-a-domain + // However, this seems to only get triggered if a wallet pubkey only contains digits so this is pretty improbable. + const url = new URL(walletConnectUrl) + const params = {} + params.walletPubkey = url.host + const secret = url.searchParams.get('secret') + const relayUrls = url.searchParams.getAll('relay') + if (secret) { + params.secret = secret + } + if (relayUrls) { + params.relayUrls = relayUrls + } + return params +} + +export const socketValidator = (msg = 'invalid socket') => + string() + .test({ + name: 'socket', + message: msg, + test: value => { + try { + const url = new URL(`http://${value}`) + return url.hostname && url.port && !url.username && !url.password && + (!url.pathname || url.pathname === '/') && !url.search && !url.hash + } catch (e) { + return false + } + }, + exclusive: false + }) + +export const runeValidator = ({ method }) => + string() + .matches(B64_URL_REGEX, { message: 'invalid rune' }) + .test({ + name: 'rune', + test: (v, context) => { + const decoded = decodeRune(v) + if (!decoded) return context.createError({ message: 'invalid rune' }) + if (decoded.restrictions.length === 0) { + return context.createError({ message: `rune must be restricted to method=${method}` }) + } + if (decoded.restrictions.length !== 1 || decoded.restrictions[0].alternatives.length !== 1) { + return context.createError({ message: `rune must be restricted to method=${method} only` }) + } + if (decoded.restrictions[0].alternatives[0] !== `method=${method}`) { + return context.createError({ message: `rune must be restricted to method=${method} only` }) + } + return true + } + }) + +export const invoiceMacaroonValidator = () => + string() + .hexOrBase64() + .test({ + name: 'macaroon', + test: v => isInvoiceMacaroon(v) || isInvoicableMacaroon(v), + message: 'not an invoice macaroon or an invoicable macaroon' + }) + +export const bip39Validator = () => + string() + .test({ + name: 'bip39', + test: async (value, context) => { + const words = value ? value.trim().split(/[\s]+/) : [] + for (const w of words) { + try { + await string().oneOf(bip39Words).validate(w) + } catch { + return context.createError({ message: `'${w}' is not a valid pairing phrase word` }) + } + } + if (words.length < 2) { + return context.createError({ message: 'needs at least two words' }) + } + if (words.length > 10) { + return context.createError({ message: 'max 10 words' }) + } + return true + } + }) + +export const certValidator = () => string().hexOrBase64() + +export const urlValidator = (...args) => + process.env.NODE_ENV === 'development' + ? string() + .or([ + string().matches(/^(http:\/\/)?localhost:\d+$/), + string().url() + ], 'invalid url') + .trim() + : string().url().trim() + .test(async (url, context) => { + if (args.includes('tor') && TOR_REGEXP.test(url)) { + // allow HTTP and HTTPS over Tor + if (!/^https?:\/\//.test(url)) { + return context.createError({ message: 'http or https required' }) + } + return true + } + + if (args.includes('clearnet')) { + try { + // force HTTPS over clearnet + await string().https().validate(url) + } catch (err) { + return context.createError({ message: err.message }) + } + } + + return true + }) + +export const hexValidator = (length) => string().hex().length(length, `must be exactly ${length} hex chars`) diff --git a/wallets/lib/wallets.json b/wallets/lib/wallets.json new file mode 100644 index 00000000..03061d45 --- /dev/null +++ b/wallets/lib/wallets.json @@ -0,0 +1,161 @@ +[ + { + "name": "BLINK", + "displayName": "Blink", + "image": "/wallets/blink.svg", + "url": "https://blink.sv/" + }, + { + "name": "CLN", + "displayName": "Core Lightning", + "image": "/wallets/cln.svg", + "url": "https://corelightning.org/" + }, + { + "name": "LNBITS", + "displayName": "LNbits", + "image": "/wallets/lnbits.svg", + "url": "https://lnbits.com/" + }, + { + "name": "LND", + "displayName": "Lightning Network Daemon", + "image": "/wallets/lnd.png", + "url": "https://docs.lightning.engineering/lightning-network-tools/lnd" + }, + { + "name": "PHOENIXD", + "displayName": "Phoenixd", + "image": "/wallets/phoenixd.png", + "url": "https://phoenix.acinq.co/server" + }, + { + "name": "ZEUS", + "displayName": "Zeus", + "image": "/wallets/zeus.svg", + "url": { + "wallet": "https://zeusln.com/", + "lud16Domain": "zeuspay.com" + } + }, + { + "name": "COINOS", + "displayName": "Coinos", + "image": "/wallets/coinos.svg", + "url": "https://coinos.io/" + }, + { + "name": "PRIMAL", + "displayName": "Primal", + "image": "/wallets/primal.svg", + "url": "https://primal.net/" + }, + { + "name": "WALLET_OF_SATOSHI", + "displayName": "Wallet of Satoshi", + "image": "/wallets/wos.svg", + "url": "https://walletofsatoshi.com/" + }, + { + "name": "VOLTAGE", + "displayName": "Voltage", + "image": "/wallets/voltage.svg", + "url": { + "wallet": "https://www.voltage.cloud/", + "lud16Domain": "vlt.ge" + } + }, + { + "name": "RIZFUL", + "displayName": "Rizful", + "image": "/wallets/rizful.png", + "url": "https://rizful.com/" + }, + { + "name": "SPEED", + "displayName": "Speed", + "image": "/wallets/speed.svg", + "url": "https://tryspeed.com/" + }, + { + "name": "ZBD", + "displayName": "ZBD", + "image": "/wallets/zbd.png", + "url": "https://zbd.gg/" + }, + { + "name": "STRIKE", + "displayName": "Strike", + "image": "/wallets/strike.png", + "url": "https://strike.me/" + }, + { + "name": "MINIBITS", + "displayName": "Minibits", + "image": "/wallets/minibits.png", + "url": "https://minibits.cash/" + }, + { + "name": "FOUNTAIN", + "displayName": "Fountain", + "image": "/wallets/fountain.png", + "url": "https://fountain.fm/" + }, + { + "name": "LIFPAY", + "displayName": "Lifpay", + "image": "/wallets/lifpay.png", + "url": "https://lifpay.me/" + }, + { + "name": "SHOCKWALLET", + "displayName": "Shockwallet", + "image": "/wallets/shockwallet.png", + "url": "https://shockwallet.app/" + }, + { + "name": "ALBY", + "displayName": "Alby", + "image": "/wallets/alby.svg", + "url": "https://getalby.com/" + }, + { + "name": "BLIXT", + "displayName": "Blixt", + "image": "/wallets/blixt.svg", + "url": "https://blixtwallet.github.io/" + }, + { + "name": "NPUB_CASH", + "displayName": "npub.cash", + "image": "/wallets/npub-cash.svg", + "url": "https://npub.cash/" + }, + { + "name": "LN_ADDR", + "displayName": "Lightning Address", + "image": "/wallets/lnaddr.png", + "url": { + "wallet": "https://github.com/lnurl/luds/blob/luds/16.md", + "lud16Domain": null + } + }, + { + "name": "NWC", + "displayName": "Nostr Wallet Connect", + "image": "/wallets/nwc.png", + "url": "https://github.com/nostr-protocol/nips/blob/master/47.md" + }, + { + "name": "CASHU_ME", + "displayName": "Cashu.me", + "image": "/wallets/cashu.me.png", + "url": "https://cashu.me/" + }, + { + "name": "CASH_APP", + "displayName": "Cash App", + "image": "/wallets/cashapp.webp", + "url": "https://cash.app/" + } +] diff --git a/wallets/lightning-address/client.js b/wallets/lightning-address/client.js deleted file mode 100644 index 9c6b469e..00000000 --- a/wallets/lightning-address/client.js +++ /dev/null @@ -1 +0,0 @@ -export * from '@/wallets/lightning-address' diff --git a/wallets/lightning-address/index.js b/wallets/lightning-address/index.js deleted file mode 100644 index 83d8689f..00000000 --- a/wallets/lightning-address/index.js +++ /dev/null @@ -1,22 +0,0 @@ -import { externalLightningAddressValidator } from '@/lib/validate' - -export const name = 'lightning-address' -export const shortName = 'lnAddr' -export const walletType = 'LIGHTNING_ADDRESS' -export const walletField = 'walletLightningAddress' - -export const fields = [ - { - name: 'address', - label: 'lightning address', - type: 'text', - autoComplete: 'off', - serverOnly: true, - validate: externalLightningAddressValidator - } -] - -export const card = { - title: 'lightning address', - subtitle: 'receive zaps to your lightning address' -} diff --git a/wallets/lnbits/index.js b/wallets/lnbits/index.js deleted file mode 100644 index 492086f7..00000000 --- a/wallets/lnbits/index.js +++ /dev/null @@ -1,59 +0,0 @@ -import { TOR_REGEXP } from '@/lib/url' -import { string } from '@/lib/yup' - -export const name = 'lnbits' -export const walletType = 'LNBITS' -export const walletField = 'walletLNbits' - -export const fields = [ - { - name: 'url', - label: 'lnbits url', - type: 'text', - validate: process.env.NODE_ENV === 'development' - ? string() - .or([string().matches(/^(http:\/\/)?localhost:\d+$/), string().url()], 'invalid url') - .trim() - : string().url().trim() - .test(async (url, context) => { - if (TOR_REGEXP.test(url)) { - // allow HTTP and HTTPS over Tor - if (!/^https?:\/\//.test(url)) { - return context.createError({ message: 'http or https required' }) - } - return true - } - try { - // force HTTPS over clearnet - await string().https().validate(url) - } catch (err) { - return context.createError({ message: err.message }) - } - return true - }) - }, - { - name: 'invoiceKey', - label: 'invoice key', - type: 'password', - optional: 'for receiving', - serverOnly: true, - requiredWithout: 'adminKey', - validate: string().hex().length(32) - }, - { - name: 'adminKey', - label: 'admin key', - type: 'password', - optional: 'for sending', - clientOnly: true, - requiredWithout: 'invoiceKey', - validate: string().hex().length(32) - } -] - -export const card = { - title: 'LNbits', - subtitle: 'use [LNbits](https://lnbits.com/) for payments', - image: { src: '/wallets/lnbits.svg' } -} diff --git a/wallets/lnc/index.js b/wallets/lnc/index.js deleted file mode 100644 index c039678b..00000000 --- a/wallets/lnc/index.js +++ /dev/null @@ -1,64 +0,0 @@ -import bip39Words from '@/lib/bip39-words' -import { string } from '@/lib/yup' - -export const name = 'lnc' -export const walletType = 'LNC' -export const walletField = 'walletLNC' - -export const fields = [ - { - name: 'pairingPhrase', - label: 'pairing phrase', - type: 'password', - help: 'We only need permissions for the uri `/lnrpc.Lightning/SendPaymentSync`\n\nCreate a budgeted account with narrow permissions:\n\n```$ litcli accounts create --balance ```\n\n```$ litcli sessions add --type custom --label --account_id --uri /lnrpc.Lightning/SendPaymentSync```\n\nGrab the `pairing_secret_mnemonic` from the output and paste it here.', - editable: false, - clientOnly: true, - validate: string() - .test(async (value, context) => { - const words = value ? value.trim().split(/[\s]+/) : [] - for (const w of words) { - try { - await string().oneOf(bip39Words).validate(w) - } catch { - return context.createError({ message: `'${w}' is not a valid pairing phrase word` }) - } - } - if (words.length < 2) { - return context.createError({ message: 'needs at least two words' }) - } - if (words.length > 10) { - return context.createError({ message: 'max 10 words' }) - } - return true - }) - }, - { - name: 'localKey', - type: 'text', - hidden: true, - clientOnly: true, - generated: true, - validate: string() - }, - { - name: 'remoteKey', - type: 'text', - hidden: true, - clientOnly: true, - generated: true, - validate: string() - }, - { - name: 'serverHost', - type: 'text', - hidden: true, - clientOnly: true, - generated: true, - validate: string() - } -] - -export const card = { - title: 'LNC', - subtitle: 'use Lightning Node Connect for LND payments' -} diff --git a/wallets/lnd/client.js b/wallets/lnd/client.js deleted file mode 100644 index e8e85d29..00000000 --- a/wallets/lnd/client.js +++ /dev/null @@ -1 +0,0 @@ -export * from '@/wallets/lnd' diff --git a/wallets/lnd/index.js b/wallets/lnd/index.js deleted file mode 100644 index f47b451e..00000000 --- a/wallets/lnd/index.js +++ /dev/null @@ -1,54 +0,0 @@ -import { isInvoicableMacaroon, isInvoiceMacaroon } from '@/lib/macaroon' -import { string } from '@/lib/yup' - -export const name = 'lnd' -export const walletType = 'LND' -export const walletField = 'walletLND' - -export const fields = [ - { - name: 'socket', - label: 'grpc host and port', - type: 'text', - placeholder: '55.5.555.55:10001', - hint: 'tor or clearnet', - clear: true, - serverOnly: true, - validate: string().socket() - }, - { - name: 'macaroon', - label: 'invoice macaroon', - help: { - label: 'privacy tip', - text: 'We accept a prebaked ***invoice.macaroon*** for your convenience. To gain better privacy, generate a new macaroon as follows:\n\n```lncli bakemacaroon invoices:write invoices:read```' - }, - type: 'text', - placeholder: 'AgEDbG5kAlgDChCn7YgfWX7uTkQQgXZ2uahNEgEwGhYKB2FkZHJlc3MSBHJlYWQSBXdyaXRlGhcKCGludm9pY2VzEgRyZWFkEgV3cml0ZRoPCgdvbmNoYWluEgRyZWFkAAAGIJkMBrrDV0npU90JV0TGNJPrqUD8m2QYoTDjolaL6eBs', - hint: 'hex or base64 encoded', - clear: true, - serverOnly: true, - validate: string().hexOrBase64().test({ - name: 'macaroon', - test: v => isInvoiceMacaroon(v) || isInvoicableMacaroon(v), - message: 'not an invoice macaroon or an invoicable macaroon' - }) - }, - { - name: 'cert', - label: 'cert', - type: 'text', - placeholder: 'LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNNVENDQWRpZ0F3SUJBZ0lRSHBFdFdrcGJwZHV4RVF2eVBPc3NWVEFLQmdncWhrak9QUVFEQWpBdk1SOHcKSFFZRFZRUUtFeFpzYm1RZ1lYVjBiMmRsYm1WeVlYUmxaQ0JqWlhKME1Rd3dDZ1lEVlFRREV3TmliMkl3SGhjTgpNalF3TVRBM01qQXhORE0wV2hjTk1qVXdNekF6TWpBeE5ETTBXakF2TVI4d0hRWURWUVFLRXhac2JtUWdZWFYwCmIyZGxibVZ5WVhSbFpDQmpaWEowTVF3d0NnWURWUVFERXdOaWIySXdXVEFUQmdjcWhrak9QUUlCQmdncWhrak8KUFFNQkJ3TkNBQVJUS3NMVk5oZnhqb1FLVDlkVVdDbzUzSmQwTnBuL1BtYi9LUE02M1JxbU52dFYvdFk4NjJJZwpSbE41cmNHRnBEajhUeFc2OVhIK0pTcHpjWDdlN3N0Um80SFZNSUhTTUE0R0ExVWREd0VCL3dRRUF3SUNwREFUCkJnTlZIU1VFRERBS0JnZ3JCZ0VGQlFjREFUQVBCZ05WSFJNQkFmOEVCVEFEQVFIL01CMEdBMVVkRGdRV0JCVDAKMnh3V25GeHRUNzI0MWxwZlNoNm9FWi9UMWpCN0JnTlZIUkVFZERCeWdnTmliMktDQ1d4dlkyRnNhRzl6ZElJRApZbTlpZ2d4d2IyeGhjaTF1TVMxaWIyS0NGR2h2YzNRdVpHOWphMlZ5TG1sdWRHVnlibUZzZ2dSMWJtbDRnZ3AxCmJtbDRjR0ZqYTJWMGdnZGlkV1pqYjI1dWh3Ui9BQUFCaHhBQUFBQUFBQUFBQUFBQUFBQUFBQUFCaHdTc0VnQUQKTUFvR0NDcUdTTTQ5QkFNQ0EwY0FNRVFDSUEwUTlkRXdoNXpPRnpwL3hYeHNpemh5SkxNVG5yazU1VWx1NHJPRwo4WW52QWlBVGt4U3p3Y3hZZnFscGx0UlNIbmd0NUJFcDBzcXlHL05nenBzb2pmMGNqQT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K', - optional: 'optional if from [CA](https://en.wikipedia.org/wiki/Certificate_authority) (e.g. voltage)', - hint: 'hex or base64 encoded', - clear: true, - serverOnly: true, - validate: string().hexOrBase64() - } -] - -export const card = { - title: 'LND', - subtitle: 'receive zaps to your Lightning Labs node', - image: { src: '/wallets/lnd.png' } -} diff --git a/wallets/logger.js b/wallets/logger.js deleted file mode 100644 index 8cf943f8..00000000 --- a/wallets/logger.js +++ /dev/null @@ -1,360 +0,0 @@ -import { useCallback, useMemo, useState, useEffect, useRef } from 'react' -import { decode as bolt11Decode } from 'bolt11' -import { formatMsats } from '@/lib/format' -import { walletTag, getWalletByType } from '@/wallets/common' -import { useMe } from '@/components/me' -import useIndexedDB, { getDbName } from '@/components/use-indexeddb' -import { useShowModal } from '@/components/modal' -import LogMessage from '@/components/log-message' -import { useToast } from '@/components/toast' -import { useMutation, useLazyQuery, gql } from '@apollo/client' -import { useRouter } from 'next/router' -import { WALLET_LOGS } from '@/fragments/wallet' -import { SSR } from '@/lib/constants' -import { Button } from 'react-bootstrap' -import styles from '@/styles/log.module.css' - -const INDICES = [ - { name: 'ts', keyPath: 'ts' }, - { name: 'wallet_ts', keyPath: ['wallet', 'ts'] } -] - -export function useWalletLoggerFactory () { - const { appendLog } = useWalletLogManager() - - const log = useCallback((wallet, level) => (message, context = {}) => { - if (!wallet) { - return - } - - if (context?.bolt11) { - // automatically populate context from bolt11 to avoid duplicating this code - const decoded = bolt11Decode(context.bolt11) - context = { - ...context, - amount: formatMsats(decoded.millisatoshis), - payment_hash: decoded.tagsObject.payment_hash, - description: decoded.tagsObject.description, - created_at: new Date(decoded.timestamp * 1000).toISOString(), - expires_at: new Date(decoded.timeExpireDate * 1000).toISOString(), - // payments should affect wallet status - status: true - } - } - context.send = true - - appendLog(wallet, level, message, context) - console[level !== 'error' ? 'info' : 'error'](`[${walletTag(wallet.def)}]`, message) - }, [appendLog]) - - return useCallback(wallet => ({ - ok: (message, context) => log(wallet, 'ok')(message, context), - info: (message, context) => log(wallet, 'info')(message, context), - error: (message, context) => log(wallet, 'error')(message, context) - }), [log]) -} - -export function useWalletLogger (wallet) { - const factory = useWalletLoggerFactory() - return factory(wallet) -} - -export function WalletLogs ({ wallet, embedded }) { - const { logs, setLogs, hasMore, loadMore, loading } = useWalletLogs(wallet) - - const showModal = useShowModal() - - return ( - <> -
- { - showModal(onClose => ) - }} - >clear logs - -
-
- - - - - - - - - - {logs.map((log, i) => ( - - ))} - -
- {loading - ?
loading...
- : logs.length === 0 &&
empty
} - {hasMore - ?
- :
------ start of logs ------
} -
- - ) -} - -function DeleteWalletLogsObstacle ({ wallet, setLogs, onClose }) { - const { deleteLogs } = useWalletLogManager(setLogs) - const toaster = useToast() - - let prompt = 'Do you really want to delete all wallet logs?' - if (wallet) { - prompt = 'Do you really want to delete all logs of this wallet?' - } - - return ( -
- {prompt} -
- cancel - -
-
- ) -} - -export function useWalletLogManager (setLogs) { - const { add, clear, notSupported } = useWalletLogDB() - - const appendLog = useCallback(async (wallet, level, message, context) => { - const log = { wallet: walletTag(wallet.def), level, message, ts: +new Date(), context } - try { - if (notSupported) { - console.log('cannot persist wallet log: indexeddb not supported') - } else { - await add(log) - } - setLogs?.(prevLogs => [log, ...prevLogs]) - } catch (error) { - console.error('Failed to append wallet log:', error) - } - }, [add, notSupported]) - - const [deleteServerWalletLogs] = useMutation( - gql` - mutation deleteWalletLogs($wallet: String) { - deleteWalletLogs(wallet: $wallet) - } - `, - { - onCompleted: (_, { variables: { wallet: walletType } }) => { - setLogs?.(logs => logs.filter(l => walletType ? l.wallet !== walletTag(getWalletByType(walletType)) : false)) - } - } - ) - - const deleteLogs = useCallback(async (wallet, options) => { - if ((!wallet || wallet.def.walletType) && !options?.clientOnly) { - await deleteServerWalletLogs({ variables: { wallet: wallet?.def.walletType } }) - } - if (!wallet || wallet.def.sendPayment) { - try { - const tag = wallet ? walletTag(wallet.def) : null - if (notSupported) { - console.log('cannot clear wallet logs: indexeddb not supported') - } else { - await clear('wallet_ts', tag ? window.IDBKeyRange.bound([tag, 0], [tag, Infinity]) : null) - } - setLogs?.(logs => logs.filter(l => wallet ? l.wallet !== tag : false)) - } catch (e) { - console.error('failed to delete logs', e) - } - } - }, [clear, deleteServerWalletLogs, setLogs, notSupported]) - - return { appendLog, deleteLogs } -} - -export function useWalletLogs (wallet, initialPage = 1, logsPerPage = 10) { - const [logs, _setLogs] = useState([]) - const [page, setPage] = useState(initialPage) - const [hasMore, setHasMore] = useState(true) - const [cursor, setCursor] = useState(null) - const [loading, setLoading] = useState(true) - const latestTimestamp = useRef() - const { me } = useMe() - const router = useRouter() - - const { getPage, error, notSupported } = useWalletLogDB() - const [getWalletLogs] = useLazyQuery(WALLET_LOGS, SSR ? {} : { fetchPolicy: 'cache-and-network' }) - - const setLogs = useCallback((action) => { - _setLogs(action) - // action can be a React state dispatch function - const newLogs = typeof action === 'function' ? action(logs) : action - // make sure 'more' button is removed if logs were deleted - if (newLogs.length === 0) setHasMore(false) - latestTimestamp.current = newLogs[0]?.ts - }, [logs, _setLogs, setHasMore]) - - const loadLogsPage = useCallback(async (page, pageSize, walletDef, variables = {}) => { - try { - let result = { data: [], hasMore: false } - if (notSupported) { - console.log('cannot get client wallet logs: indexeddb not supported') - } else { - const indexName = walletDef ? 'wallet_ts' : 'ts' - const query = walletDef ? window.IDBKeyRange.bound([walletTag(walletDef), -Infinity], [walletTag(walletDef), Infinity]) : null - - result = await getPage(page, pageSize, indexName, query, 'prev') - // if given wallet has no walletType it means logs are only stored in local IDB - if (walletDef && !walletDef.walletType) { - return result - } - } - - const oldestTs = result?.data[result.data.length - 1]?.ts // start of local logs - const newestTs = result?.data[0]?.ts // end of local logs - - let from - if (variables?.from !== undefined) { - from = variables.from - } else if (oldestTs && result.hasMore) { - // fetch all missing, intertwined server logs since start of local logs - from = String(oldestTs) - } else { - from = null - } - - let to - if (variables?.to !== undefined) { - to = variables.to - } else if (newestTs && cursor) { - // fetch next old page of server logs - // ( if cursor is available, we will use decoded time of cursor ) - to = String(newestTs) - } else { - to = null - } - - const { data } = await getWalletLogs({ - variables: { - type: walletDef?.walletType, - from, - to, - cursor, - ...variables - } - }) - - const newLogs = data.walletLogs.entries.map(({ createdAt, wallet: walletType, ...log }) => ({ - ts: +new Date(createdAt), - wallet: walletTag(getWalletByType(walletType)), - ...log, - // required to resolve recv status - context: { - recv: true, - status: !!log.context?.bolt11 && ['warn', 'error', 'success'].includes(log.level.toLowerCase()), - ...log.context - } - })) - const combinedLogs = uniqueSort([...result.data, ...newLogs]) - - setCursor(data.walletLogs.cursor) - return { - ...result, - data: combinedLogs, - hasMore: result.hasMore || !!data.walletLogs.cursor - } - } catch (error) { - console.error('Error loading logs from IndexedDB:', error) - return { data: [], hasMore: false } - } - }, [getPage, setCursor, cursor, notSupported]) - - if (error) { - console.error('IndexedDB error:', error) - } - - const loadMore = useCallback(async () => { - if (hasMore) { - setLoading(true) - const result = await loadLogsPage(page + 1, logsPerPage, wallet?.def) - setLogs(prevLogs => uniqueSort([...prevLogs, ...result.data])) - setHasMore(result.hasMore) - setPage(prevPage => prevPage + 1) - setLoading(false) - } - }, [setLogs, loadLogsPage, page, logsPerPage, wallet?.def, hasMore]) - - const loadNew = useCallback(async () => { - const latestTs = latestTimestamp.current - const variables = { from: latestTs?.toString(), to: null } - const result = await loadLogsPage(1, logsPerPage, wallet?.def, variables) - setLoading(false) - _setLogs(prevLogs => uniqueSort([...result.data, ...prevLogs])) - if (!latestTs) { - // we only want to update the more button if we didn't fetch new logs since it is about old logs. - // we didn't fetch new logs if this is our first fetch (no newest timestamp available) - setHasMore(result.hasMore) - } - }, [wallet?.def, loadLogsPage]) - - useEffect(() => { - // only fetch new logs if we are on a page that uses logs - const needLogs = router.asPath.startsWith('/wallets') - if (!me || !needLogs) return - - let timeout - let stop = false - - const poll = async () => { - await loadNew().catch(console.error) - if (!stop) timeout = setTimeout(poll, 1_000) - } - - timeout = setTimeout(poll, 1_000) - - return () => { - stop = true - clearTimeout(timeout) - } - }, [me?.id, router.pathname, loadNew]) - - return { logs, hasMore: !loading && hasMore, loadMore, setLogs, loading } -} - -function uniqueSort (logs) { - return Array.from(new Set(logs.map(JSON.stringify))).map(JSON.parse).sort((a, b) => b.ts - a.ts) -} - -function getWalletLogDbName (userId) { - return getDbName(userId) -} - -function useWalletLogDB () { - const { me } = useMe() - // memoize the idb config to avoid re-creating it on every render - const idbConfig = useMemo(() => - ({ dbName: getWalletLogDbName(me?.id), storeName: 'wallet_logs', indices: INDICES }), [me?.id]) - const { add, getPage, clear, error, notSupported } = useIndexedDB(idbConfig) - - return { add, getPage, clear, error, notSupported } -} diff --git a/wallets/nwc/client.js b/wallets/nwc/client.js deleted file mode 100644 index 19391cc4..00000000 --- a/wallets/nwc/client.js +++ /dev/null @@ -1,14 +0,0 @@ -import { supportedMethods, nwcTryRun } from '@/wallets/nwc' -export * from '@/wallets/nwc' - -export async function testSendPayment ({ nwcUrl }, { signal }) { - const supported = await supportedMethods(nwcUrl, { signal }) - if (!supported.includes('pay_invoice')) { - throw new Error('pay_invoice not supported') - } -} - -export async function sendPayment (bolt11, { nwcUrl }, { signal }) { - const result = await nwcTryRun(nwc => nwc.lnPay({ pr: bolt11 }), { nwcUrl }, { signal }) - return result.preimage -} diff --git a/wallets/nwc/index.js b/wallets/nwc/index.js deleted file mode 100644 index 4c6e3f63..00000000 --- a/wallets/nwc/index.js +++ /dev/null @@ -1,69 +0,0 @@ -import Nostr from '@/lib/nostr' -import { string } from '@/lib/yup' -import { parseNwcUrl } from '@/lib/url' -import { NDKNWCWallet } from '@nostr-dev-kit/ndk-wallet' - -export const name = 'nwc' -export const walletType = 'NWC' -export const walletField = 'walletNWC' - -export const fields = [ - { - name: 'nwcUrl', - label: 'connection', - type: 'password', - optional: 'for sending', - clientOnly: true, - requiredWithout: 'nwcUrlRecv', - validate: string().nwcUrl() - }, - { - name: 'nwcUrlRecv', - label: 'connection', - type: 'password', - optional: 'for receiving', - serverOnly: true, - requiredWithout: 'nwcUrl', - validate: string().nwcUrl() - } -] - -export const card = { - title: 'NWC', - subtitle: 'use Nostr Wallet Connect for payments' -} - -async function getNwc (nostr, nwcUrl, { signal }) { - const ndk = nostr.ndk - const { walletPubkey, secret, relayUrls } = parseNwcUrl(nwcUrl) - const nwc = new NDKNWCWallet(ndk, { - pubkey: walletPubkey, - relayUrls, - secret - }) - return nwc -} - -/** - * Run a nwc function and throw if it errors - * (workaround to handle ambiguous NDK error handling) - * @param {function} fun - the nwc function to run - * @returns - the result of the nwc function - */ -export async function nwcTryRun (fun, { nwcUrl }, { signal }) { - const nostr = new Nostr() - try { - const nwc = await getNwc(nostr, nwcUrl, { signal }) - return await fun(nwc) - } catch (e) { - if (e.error) throw new Error(e.error.message || e.error.code) - throw e - } finally { - nostr.close() - } -} - -export async function supportedMethods (nwcUrl, { signal }) { - const result = await nwcTryRun(nwc => nwc.getInfo(), { nwcUrl }, { signal }) - return result.methods -} diff --git a/wallets/nwc/server.js b/wallets/nwc/server.js deleted file mode 100644 index 4fe8c48a..00000000 --- a/wallets/nwc/server.js +++ /dev/null @@ -1,29 +0,0 @@ -import { supportedMethods, nwcTryRun } from '@/wallets/nwc' -export * from '@/wallets/nwc' - -export async function testCreateInvoice ({ nwcUrlRecv }, { signal }) { - const supported = await supportedMethods(nwcUrlRecv, { signal }) - - const supports = (method) => supported.includes(method) - - if (!supports('make_invoice')) { - throw new Error('make_invoice not supported') - } - - const mustNotSupport = ['pay_invoice', 'multi_pay_invoice', 'pay_keysend', 'multi_pay_keysend'] - for (const method of mustNotSupport) { - if (supports(method)) { - throw new Error(`${method} must not be supported`) - } - } - - return await createInvoice({ msats: 1000, expiry: 1 }, { nwcUrlRecv }, { signal }) -} - -export async function createInvoice ({ msats, description, expiry }, { nwcUrlRecv }, { signal }) { - const result = await nwcTryRun( - nwc => nwc.req('make_invoice', { amount: msats, description, expiry }), - { nwcUrl: nwcUrlRecv }, { signal } - ) - return result.result.invoice -} diff --git a/wallets/phoenixd/index.js b/wallets/phoenixd/index.js deleted file mode 100644 index ad7f19ee..00000000 --- a/wallets/phoenixd/index.js +++ /dev/null @@ -1,42 +0,0 @@ -import { string } from '@/lib/yup' - -export const name = 'phoenixd' -export const walletType = 'PHOENIXD' -export const walletField = 'walletPhoenixd' - -// configure wallet fields -export const fields = [ - { - name: 'url', - label: 'url', - type: 'text', - validate: string().url().trim() - }, - { - name: 'primaryPassword', - label: 'primary password', - type: 'password', - optional: 'for sending', - help: 'You can find the primary password as `http-password` in your phoenixd configuration file as mentioned [here](https://phoenix.acinq.co/server/api#security).', - clientOnly: true, - requiredWithout: 'secondaryPassword', - validate: string().length(64).hex() - }, - { - name: 'secondaryPassword', - label: 'secondary password', - type: 'password', - optional: 'for receiving', - help: 'You can find the secondary password as `http-password-limited-access` in your phoenixd configuration file as mentioned [here](https://phoenix.acinq.co/server/api#security).', - serverOnly: true, - requiredWithout: 'primaryPassword', - validate: string().length(64).hex() - } -] - -// configure wallet card -export const card = { - title: 'phoenixd', - subtitle: 'use [phoenixd](https://phoenix.acinq.co/server) for payments', - image: { src: '/wallets/phoenixd.png' } -} diff --git a/wallets/server/index.js b/wallets/server/index.js new file mode 100644 index 00000000..39a796a5 --- /dev/null +++ b/wallets/server/index.js @@ -0,0 +1,3 @@ +export * from './logger' +export * from './wrap' +export * from './receive' diff --git a/wallets/server/logger.js b/wallets/server/logger.js new file mode 100644 index 00000000..4b9ab566 --- /dev/null +++ b/wallets/server/logger.js @@ -0,0 +1,72 @@ +import { formatMsats } from '@/lib/format' +import { parsePaymentRequest } from 'ln-service' + +export function walletLogger ({ + models, + protocolId, + userId, + invoiceId, + withdrawalId +}) { + // server implementation of wallet logger interface on client + const log = (level) => async (message, context = {}) => { + // if no timestamp is given, set createdAt to time when logger was called to keep logs in order + // since logs are created asynchronously and thus might get inserted out of order + // however, millisecond precision is not always enough ... + const createdAt = context?.createdAt ?? new Date() + + const updateStatus = ['OK', 'ERROR', 'WARNING'].includes(level) && (invoiceId || withdrawalId || context.bolt11 || context?.updateStatus) + delete context?.updateStatus + + try { + if (context.bolt11) { + // automatically populate context from bolt11 to avoid duplicating this code + // (this is needed because in some cases we want to log before we have an invoice or withdrawal id) + context = { + ...context, + ...await logContextFromBolt11(context.bolt11) + } + } + + await models.$transaction([ + models.walletLog.create({ + data: { + userId, + protocolId, + level, + message, + context, + invoiceId, + withdrawalId, + createdAt + } + }), + updateStatus && models.walletProtocol.update({ + where: { id: protocolId }, + data: { status: level } + }) + ].filter(Boolean)) + } catch (err) { + console.error('error creating wallet log:', err) + } + } + + return { + ok: (message, context) => log('OK')(message, context), + info: (message, context) => log('INFO')(message, context), + error: (message, context) => log('ERROR')(message, context), + warn: (message, context) => log('WARNING')(message, context) + } +} + +export async function logContextFromBolt11 (bolt11) { + const decoded = await parsePaymentRequest({ request: bolt11 }) + return { + bolt11, + amount: formatMsats(decoded.mtokens), + payment_hash: decoded.id, + created_at: decoded.created_at, + expires_at: decoded.expires_at, + description: decoded.description + } +} diff --git a/wallets/blink/server.js b/wallets/server/protocols/blink.js similarity index 95% rename from wallets/blink/server.js rename to wallets/server/protocols/blink.js index 0d1f2748..01d1b91b 100644 --- a/wallets/blink/server.js +++ b/wallets/server/protocols/blink.js @@ -1,22 +1,7 @@ -import { getScopes, SCOPE_READ, SCOPE_RECEIVE, SCOPE_WRITE, getWallet, request } from '@/wallets/blink/common' +import { getScopes, SCOPE_READ, SCOPE_RECEIVE, SCOPE_WRITE, getWallet, request } from '@/wallets/lib/protocols/blink' import { msatsToSats } from '@/lib/format' -export * from '@/wallets/blink' -export async function testCreateInvoice ({ apiKeyRecv, currencyRecv }, { signal }) { - const scopes = await getScopes({ apiKey: apiKeyRecv }, { signal }) - if (!scopes.includes(SCOPE_READ)) { - throw new Error('missing READ scope') - } - if (scopes.includes(SCOPE_WRITE)) { - throw new Error('WRITE scope must not be present') - } - if (!scopes.includes(SCOPE_RECEIVE)) { - throw new Error('missing RECEIVE scope') - } - - currencyRecv = currencyRecv ? currencyRecv.toUpperCase() : 'BTC' - return await createInvoice({ msats: 1000, expiry: 1 }, { apiKeyRecv, currencyRecv }, { signal }) -} +export const name = 'BLINK' export async function createInvoice ( { msats, description, expiry }, @@ -61,3 +46,19 @@ export async function createInvoice ( return res.invoice.paymentRequest } + +export async function testCreateInvoice ({ apiKeyRecv, currencyRecv }, { signal }) { + const scopes = await getScopes({ apiKey: apiKeyRecv }, { signal }) + if (!scopes.includes(SCOPE_READ)) { + throw new Error('missing READ scope') + } + if (scopes.includes(SCOPE_WRITE)) { + throw new Error('WRITE scope must not be present') + } + if (!scopes.includes(SCOPE_RECEIVE)) { + throw new Error('missing RECEIVE scope') + } + + currencyRecv = currencyRecv ? currencyRecv.toUpperCase() : 'BTC' + return await createInvoice({ msats: 1000, expiry: 1 }, { apiKeyRecv, currencyRecv }, { signal }) +} diff --git a/wallets/cln/server.js b/wallets/server/protocols/clnRest.js similarity index 69% rename from wallets/cln/server.js rename to wallets/server/protocols/clnRest.js index 2916e944..6235cf9f 100644 --- a/wallets/cln/server.js +++ b/wallets/server/protocols/clnRest.js @@ -1,15 +1,12 @@ import { createInvoice as clnCreateInvoice } from '@/lib/cln' -export * from '@/wallets/cln' - -export const testCreateInvoice = async ({ socket, rune, cert }, { signal }) => { - return await createInvoice({ msats: 1000, expiry: 1, description: '' }, { socket, rune, cert }, { signal }) -} +export const name = 'CLN_REST' export const createInvoice = async ( { msats, description, expiry }, { socket, rune, cert }, - { signal }) => { + { signal } +) => { const inv = await clnCreateInvoice( { msats, @@ -25,3 +22,11 @@ export const createInvoice = async ( return inv.bolt11 } + +export const testCreateInvoice = async ({ socket, rune, cert }, { signal }) => { + return await createInvoice( + { msats: 1000, expiry: 1, description: 'SN test invoice' }, + { socket, rune, cert }, + { signal } + ) +} diff --git a/wallets/server/protocols/index.js b/wallets/server/protocols/index.js new file mode 100644 index 00000000..26c292d9 --- /dev/null +++ b/wallets/server/protocols/index.js @@ -0,0 +1,60 @@ +import * as nwc from './nwc' +import * as lnbits from './lnbits' +import * as lnAddr from './lnAddr' +import * as clnRest from './clnRest' +import * as phoenixd from './phoenixd' +import * as blink from './blink' +import * as lndGrpc from './lndGrpc' + +export * from './util' + +/** + * @typedef {@import('@/wallets/lib/protocols').ProtocolName} ProtocolName + */ + +/** + * @typedef {Object} ServerWalletProtocol + * @property {ProtocolName} name - must match a protocol name in the database + * @property {ProtocolCreateInvoice} createInvoice - create a new invoice + * @property {ProtocolTestCreateInvoice} testCreateInvoice - create a test invoice + */ + +/** + * @callback ProtocolCreateInvoice + * @param {CreateInvoiceArgs} args - arguments for the invoice + * @param {Object} config - current protocol configuration + * @param {CreateInvoiceOptions} opts - additional options for the invoice request + * @returns {Promise} - bolt11 invoice + */ + +/** + * @typedef {Object} CreateInvoiceArgs + * @property {number} msats - payment amount in millisatoshis + * @property {string} description - payment description + * @property {number} expiry - expiry time in seconds + */ + +/** + * @typedef {Object} CreateInvoiceOptions + * @property {AbortSignal} signal - signal to abort the request + */ + +/** + * @callback ProtocolTestCreateInvoice + * @param {Object} config - current protocol configuration + * @param {CreateInvoiceOptions} opts - additional options for the invoice request + * @returns {Promise} - bolt11 invoice + */ + +/** @typedef {string} Bolt11 */ + +/** @type {ServerWalletProtocol[]} */ +export default [ + nwc, + lnbits, + lnAddr, + clnRest, + phoenixd, + blink, + lndGrpc +] diff --git a/wallets/lightning-address/server.js b/wallets/server/protocols/lnAddr.js similarity index 96% rename from wallets/lightning-address/server.js rename to wallets/server/protocols/lnAddr.js index e5aa3c94..b6828e6d 100644 --- a/wallets/lightning-address/server.js +++ b/wallets/server/protocols/lnAddr.js @@ -3,11 +3,7 @@ import { msatsSatsFloor } from '@/lib/format' import { lnAddrOptions } from '@/lib/lnurl' import { assertContentTypeJson, assertResponseOk } from '@/lib/url' -export * from '@/wallets/lightning-address' - -export const testCreateInvoice = async ({ address }, { signal }) => { - return await createInvoice({ msats: undefined }, { address }, { signal }) -} +export const name = 'LN_ADDR' export const createInvoice = async ( { msats, description }, @@ -48,3 +44,7 @@ export const createInvoice = async ( return body.pr } + +export const testCreateInvoice = async ({ address }, { signal }) => { + return await createInvoice({ msats: undefined }, { address }, { signal }) +} diff --git a/wallets/lnbits/server.js b/wallets/server/protocols/lnbits.js similarity index 86% rename from wallets/lnbits/server.js rename to wallets/server/protocols/lnbits.js index c22ce8ae..cdf3b6c1 100644 --- a/wallets/lnbits/server.js +++ b/wallets/server/protocols/lnbits.js @@ -5,22 +5,18 @@ import { getAgent } from '@/lib/proxy' import { assertContentTypeJson } from '@/lib/url' import fetch from 'cross-fetch' -export * from '@/wallets/lnbits' - -export async function testCreateInvoice ({ url, invoiceKey }, { signal }) { - return await createInvoice({ msats: 1000, expiry: 1 }, { url, invoiceKey }, { signal }) -} +export const name = 'LNBITS' export async function createInvoice ( { msats, description, descriptionHash, expiry }, - { url, invoiceKey }, + { url, apiKey }, { signal }) { const path = '/api/v1/payments' const headers = new Headers() headers.append('Accept', 'application/json') headers.append('Content-Type', 'application/json') - headers.append('X-Api-Key', invoiceKey) + headers.append('X-Api-Key', apiKey) // lnbits doesn't support msats so we have to floor to nearest sat const sats = msatsToSats(msats) @@ -69,3 +65,11 @@ export async function createInvoice ( const payment = await res.json() return payment?.payment_request || payment?.bolt11 } + +export async function testCreateInvoice ({ url, apiKey }, { signal }) { + return await createInvoice( + { msats: 1000, description: 'SN test invoice', expiry: 1 }, + { url, apiKey }, + { signal } + ) +} diff --git a/wallets/lnd/server.js b/wallets/server/protocols/lndGrpc.js similarity index 95% rename from wallets/lnd/server.js rename to wallets/server/protocols/lndGrpc.js index 6a9a7108..8ab0551e 100644 --- a/wallets/lnd/server.js +++ b/wallets/server/protocols/lndGrpc.js @@ -3,11 +3,7 @@ import { authenticatedLndGrpc } from '@/lib/lnd' import { createInvoice as lndCreateInvoice } from 'ln-service' import { TOR_REGEXP } from '@/lib/url' -export * from '@/wallets/lnd' - -export const testCreateInvoice = async ({ cert, macaroon, socket }) => { - return await createInvoice({ msats: 1000, expiry: 1 }, { cert, macaroon, socket }) -} +export const name = 'LND_GRPC' export const createInvoice = async ( { msats, description, descriptionHash, expiry }, @@ -17,9 +13,9 @@ export const createInvoice = async ( const isOnion = TOR_REGEXP.test(socket) const { lnd } = await authenticatedLndGrpc({ - cert, + socket, macaroon, - socket + cert }, isOnion) const invoice = await lndCreateInvoice({ @@ -37,3 +33,7 @@ export const createInvoice = async ( throw new Error(details) } } + +export const testCreateInvoice = async ({ cert, macaroon, socket }) => { + return await createInvoice({ msats: 1000, expiry: 1 }, { cert, macaroon, socket }) +} diff --git a/wallets/server/protocols/nwc.js b/wallets/server/protocols/nwc.js new file mode 100644 index 00000000..d1f9fcfe --- /dev/null +++ b/wallets/server/protocols/nwc.js @@ -0,0 +1,20 @@ +import { nwcTryRun } from '@/wallets/lib/protocols/nwc' + +export const name = 'NWC' + +export async function createInvoice ({ msats, description, expiry }, { url }, { signal }) { + const result = await nwcTryRun( + nwc => nwc.req('make_invoice', { amount: msats, description, expiry }), + { url }, + { signal } + ) + return result.result.invoice +} + +export async function testCreateInvoice ({ url }, { signal }) { + return await createInvoice( + { msats: 1000, description: 'SN test invoice', expiry: 1 }, + { url }, + { signal } + ) +} diff --git a/wallets/phoenixd/server.js b/wallets/server/protocols/phoenixd.js similarity index 80% rename from wallets/phoenixd/server.js rename to wallets/server/protocols/phoenixd.js index 0e836044..a56cda3b 100644 --- a/wallets/phoenixd/server.js +++ b/wallets/server/protocols/phoenixd.js @@ -3,25 +3,18 @@ import { msatsToSats } from '@/lib/format' import { getAgent } from '@/lib/proxy' import { assertContentTypeJson, assertResponseOk } from '@/lib/url' -export * from '@/wallets/phoenixd' - -export async function testCreateInvoice ({ url, secondaryPassword }, { signal }) { - return await createInvoice( - { msats: 1000, description: 'SN test invoice', expiry: 1 }, - { url, secondaryPassword }, - { signal }) -} +export const name = 'PHOENIXD' export async function createInvoice ( { msats, description, descriptionHash, expiry }, - { url, secondaryPassword }, + { url, apiKey }, { signal } ) { // https://phoenix.acinq.co/server/api#create-bolt11-invoice const path = '/createinvoice' const headers = new Headers() - headers.set('Authorization', 'Basic ' + Buffer.from(':' + secondaryPassword).toString('base64')) + headers.set('Authorization', 'Basic ' + Buffer.from(':' + apiKey).toString('base64')) headers.set('Content-type', 'application/x-www-form-urlencoded') const body = new URLSearchParams() @@ -46,3 +39,10 @@ export async function createInvoice ( const payment = await res.json() return payment.serialized } + +export async function testCreateInvoice ({ url, apiKey }, { signal }) { + return await createInvoice( + { msats: 1000, description: 'SN test invoice', expiry: 1 }, + { url, apiKey }, + { signal }) +} diff --git a/wallets/server/protocols/util.js b/wallets/server/protocols/util.js new file mode 100644 index 00000000..a92ebf03 --- /dev/null +++ b/wallets/server/protocols/util.js @@ -0,0 +1,13 @@ +import protocols from '@/wallets/server/protocols' + +function protocol (name) { + return protocols.find(protocol => protocol.name === name) +} + +export function protocolCreateInvoice ({ name }, args, config, opts) { + return protocol(name).createInvoice(args, config, opts) +} + +export function protocolTestCreateInvoice ({ name }, config, opts) { + return protocol(name).testCreateInvoice(config, opts) +} diff --git a/wallets/server.js b/wallets/server/receive.js similarity index 70% rename from wallets/server.js rename to wallets/server/receive.js index 529b70a6..48751cd4 100644 --- a/wallets/server.js +++ b/wallets/server/receive.js @@ -1,32 +1,16 @@ -// 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' -import * as blink from '@/wallets/blink/server' - -// we import only the metadata of client side wallets -import * as lnc from '@/wallets/lnc' -import * as webln from '@/wallets/webln' - -import { walletLogger } from '@/api/resolvers/wallet' import { parsePaymentRequest } from 'ln-service' -import { toPositiveBigInt, toPositiveNumber, formatMsats, formatSats, msatsToSats } from '@/lib/format' +import { formatMsats, formatSats, msatsToSats, toPositiveBigInt, toPositiveNumber } from '@/lib/format' import { PAID_ACTION_TERMINAL_STATES, WALLET_CREATE_INVOICE_TIMEOUT_MS } from '@/lib/constants' import { timeoutSignal, withTimeout } from '@/lib/time' -import { canReceive } from './common' -import wrapInvoice from './wrap' - -const walletDefs = [lnd, cln, lnAddr, lnbits, nwc, phoenixd, blink, lnc, webln] -export default walletDefs +import { wrapInvoice } from '@/wallets/server/wrap' +import { walletLogger } from '@/wallets/server/logger' +import { protocolCreateInvoice } from '@/wallets/server/protocols' const MAX_PENDING_INVOICES_PER_WALLET = 25 export async function * createUserInvoice (userId, { msats, description, descriptionHash, expiry = 360 }, { paymentAttempt, predecessorId, models }) { - // get the wallets in order of priority - const wallets = await getInvoiceableWallets(userId, { + // get the protocols in order of priority + const protocols = await getInvoiceableWallets(userId, { paymentAttempt, predecessorId, models @@ -34,8 +18,8 @@ export async function * createUserInvoice (userId, { msats, description, descrip msats = toPositiveNumber(msats) - for (const { def, wallet } of wallets) { - const logger = walletLogger({ wallet, models }) + for (const protocol of protocols) { + const logger = walletLogger({ protocolId: protocol.id, userId, models }) try { logger.info( @@ -45,10 +29,10 @@ export async function * createUserInvoice (userId, { msats, description, descrip let invoice try { - invoice = await walletCreateInvoice( - { wallet, def }, + invoice = await _protocolCreateInvoice( + protocol, { msats, description, descriptionHash, expiry }, - { logger, models }) + { models }) } catch (err) { throw new Error('failed to create invoice: ' + err.message) } @@ -71,10 +55,10 @@ export async function * createUserInvoice (userId, { msats, description, descrip } } - yield { invoice, wallet, logger } + yield { invoice, protocol, logger } } catch (err) { console.error('failed to create user invoice:', err) - logger.error(err.message, { status: true }) + logger.error(err.message, { updateStatus: true }) } } } @@ -83,7 +67,7 @@ export async function createWrappedInvoice (userId, { msats, feePercent, description, descriptionHash, expiry = 360 }, { paymentAttempt, predecessorId, models, me, lnd }) { // loop over all receiver wallet invoices until we successfully wrapped one - for await (const { invoice, logger, wallet } of createUserInvoice(userId, { + for await (const { invoice, logger, protocol } of createUserInvoice(userId, { // this is the amount the stacker will receive, the other (feePercent)% is our fee msats: toPositiveBigInt(msats) * (100n - feePercent) / 100n, description, @@ -97,12 +81,12 @@ export async function createWrappedInvoice (userId, return { invoice, wrappedInvoice: wrappedInvoice.request, - wallet, + protocol, maxFee } } catch (e) { console.error('failed to wrap invoice:', e) - logger?.error('failed to wrap invoice: ' + e.message, { bolt11 }) + logger?.warn('failed to wrap invoice: ' + e.message, { bolt11 }) } } @@ -114,29 +98,29 @@ export async function getInvoiceableWallets (userId, { paymentAttempt, predecess // the current predecessor invoice is in state 'FAILED' and not in state 'RETRYING' because we are currently retrying it // so it has not been updated yet. // if predecessorId is not provided, the subquery will be empty and thus no wallets are filtered out. - const wallets = await models.$queryRaw` + return await models.$queryRaw` SELECT - "Wallet".*, + "WalletProtocol".*, jsonb_build_object( 'id', "users"."id", 'hideInvoiceDesc', "users"."hideInvoiceDesc" ) AS "user" - FROM "Wallet" + FROM "WalletProtocol" + JOIN "Wallet" ON "WalletProtocol"."walletId" = "Wallet"."id" JOIN "users" ON "users"."id" = "Wallet"."userId" WHERE "Wallet"."userId" = ${userId} - AND "Wallet"."enabled" = true - AND "Wallet"."id" NOT IN ( + AND "WalletProtocol"."enabled" = true + AND "WalletProtocol"."send" = false + AND "WalletProtocol"."id" NOT IN ( WITH RECURSIVE "Retries" AS ( -- select the current failed invoice that we are currently retrying -- this failed invoice will be used to start the recursion SELECT "Invoice"."id", "Invoice"."predecessorId" FROM "Invoice" WHERE "Invoice"."id" = ${predecessorId} AND "Invoice"."actionState" = 'FAILED' - - UNION ALL - - -- recursive part: use predecessorId to select the previous invoice that failed in the chain + UNION ALL + -- recursive part: use predecessorId to select the previous invoice that failed in the chain -- until there is no more previous invoice SELECT "Invoice"."id", "Invoice"."predecessorId" FROM "Invoice" @@ -145,40 +129,36 @@ export async function getInvoiceableWallets (userId, { paymentAttempt, predecess AND "Invoice"."paymentAttempt" = ${paymentAttempt} ) SELECT - "InvoiceForward"."walletId" + "InvoiceForward"."protocolId" FROM "Retries" JOIN "InvoiceForward" ON "InvoiceForward"."invoiceId" = "Retries"."id" JOIN "Withdrawl" ON "Withdrawl".id = "InvoiceForward"."withdrawlId" WHERE "Withdrawl"."status" IS DISTINCT FROM 'CONFIRMED' ) ORDER BY "Wallet"."priority" ASC, "Wallet"."id" ASC` - - const walletsWithDefs = wallets.map(wallet => { - const w = walletDefs.find(w => w.walletType === wallet.type) - return { wallet, def: w } - }) - - return walletsWithDefs.filter(({ def, wallet }) => canReceive({ def, config: wallet.wallet })) } -async function walletCreateInvoice ({ wallet, def }, { +async function _protocolCreateInvoice (protocol, { msats, description, descriptionHash, expiry = 360 }, { logger, models }) { // check for pending withdrawals + + // TODO(wallet-v2): make sure this still works as intended const pendingWithdrawals = await models.withdrawl.count({ where: { - walletId: wallet.id, + protocolId: protocol.id, status: null } }) // and pending forwards + // TODO(wallet-v2): make sure this still works as intended const pendingForwards = await models.invoiceForward.count({ where: { - walletId: wallet.id, + protocolId: protocol.id, invoice: { actionState: { notIn: PAID_ACTION_TERMINAL_STATES @@ -193,14 +173,15 @@ async function walletCreateInvoice ({ wallet, def }, { } return await withTimeout( - def.createInvoice( + protocolCreateInvoice( + protocol, { msats, - description: wallet.user.hideInvoiceDesc ? undefined : description, + description: protocol.user.hideInvoiceDesc ? undefined : description, descriptionHash, expiry }, - wallet.wallet, + protocol.config, { logger, signal: timeoutSignal(WALLET_CREATE_INVOICE_TIMEOUT_MS) diff --git a/wallets/server/resolvers/index.js b/wallets/server/resolvers/index.js new file mode 100644 index 00000000..c1fa0b20 --- /dev/null +++ b/wallets/server/resolvers/index.js @@ -0,0 +1,16 @@ +import { resolvers as walletResolvers } from './wallet' +import { resolvers as protocolResolvers } from './protocol' + +export default { + ...walletResolvers, + ...protocolResolvers, + Query: { + ...walletResolvers.Query, + ...protocolResolvers.Query + }, + Mutation: { + ...walletResolvers.Mutation, + ...protocolResolvers.Mutation + } + +} diff --git a/wallets/server/resolvers/protocol.js b/wallets/server/resolvers/protocol.js new file mode 100644 index 00000000..4e2f865c --- /dev/null +++ b/wallets/server/resolvers/protocol.js @@ -0,0 +1,373 @@ +import { GqlAuthenticationError, GqlInputError } from '@/lib/error' +import { validateSchema } from '@/lib/validate' +import protocols from '@/wallets/lib/protocols' +import { protocolRelationName, isEncryptedField, protocolMutationName, protocolServerSchema } from '@/wallets/lib/util' +import { mapWalletResolveTypes } from '@/wallets/server/resolvers/util' +import { protocolTestCreateInvoice } from '@/wallets/server/protocols' +import { timeoutSignal, withTimeout } from '@/lib/time' +import { WALLET_CREATE_INVOICE_TIMEOUT_MS } from '@/lib/constants' +import { notifyNewStreak, notifyStreakLost } from '@/lib/webPush' +import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor' +import { logContextFromBolt11, walletLogger } from '@/wallets/server/logger' +import { formatMsats } from '@/lib/format' + +const WalletProtocolConfig = { + __resolveType: config => config.__resolveType +} + +const WalletLogEntry = { + context: async ({ level, context, withdrawal }) => { + const isError = ['error', 'warn'].includes(level.toLowerCase()) + + // never return invoice as context because it might leak sensitive sender details + if (withdrawal) { + return { + ...await logContextFromBolt11(withdrawal.bolt11), + ...(withdrawal.preimage ? { preimage: withdrawal.preimage } : {}), + ...(isError ? { max_fee: formatMsats(withdrawal.msatsFeePaying) } : {}) + } + } + + return context + } +} + +export const resolvers = { + WalletProtocolConfig, + WalletLogEntry, + Query: { + walletLogs + }, + Mutation: { + ...Object.fromEntries( + protocols.map(protocol => { + return [ + protocolMutationName(protocol), + upsertWalletProtocol(protocol) + ] + }) + ), + addWalletLog, + removeWalletProtocol, + deleteWalletLogs + } +} + +export function upsertWalletProtocol (protocol) { + return async (parent, { + walletId, + templateName, + enabled, + networkTests = true, + ignoreKeyHash = false, + ...args + }, { me, models, tx }) => { + if (!me) { + throw new GqlAuthenticationError() + } + + if (!walletId && !templateName) { + throw new GqlInputError('walletId or templateName is required') + } + + const { vaultKeyHash: existingKeyHash } = await models.user.findUnique({ where: { id: me.id } }) + + const schema = protocolServerSchema(protocol, { keyHash: existingKeyHash, ignoreKeyHash }) + try { + await validateSchema(schema, args) + } catch (e) { + // TODO(wallet-v2): on length errors, error message includes path twice like this: + // "apiKey.iv: apiKey.iv must be exactly 32 characters" + throw new GqlInputError(e.message) + } + + if (!protocol.send && networkTests) { + let invoice + try { + invoice = await withTimeout( + protocolTestCreateInvoice(protocol, args, { signal: timeoutSignal(WALLET_CREATE_INVOICE_TIMEOUT_MS) }), + WALLET_CREATE_INVOICE_TIMEOUT_MS + ) + } catch (e) { + throw new GqlInputError('failed to create test invoice: ' + e.message) + } + + if (!invoice || !invoice.startsWith('lnbc')) { + throw new GqlInputError('wallet returned invalid invoice') + } + } + + const relation = protocolRelationName(protocol) + + function dataFragment (args, type) { + return Object.fromEntries( + Object.entries(args).map( + ([key, value]) => { + if (isEncryptedField(protocol, key)) { + return [key, { [type]: { value: value.value, iv: value.iv } }] + } + return [key, value] + } + ) + ) + } + + // Prisma does not support nested transactions so we need to check manually if we were given a transaction + // https://github.com/prisma/prisma/issues/15212 + async function transaction (tx) { + if (templateName) { + const { id: newWalletId } = await tx.wallet.create({ + data: { + templateName, + userId: me.id + } + }) + walletId = newWalletId + } + + const wallet = await tx.wallet.update({ + where: { + id: Number(walletId), + // this makes sure that users can only update their own wallets + // (the update will fail in this case and abort the transaction) + userId: me.id + }, + data: { + protocols: { + upsert: { + where: { + WalletProtocol_walletId_send_name_key: { + walletId: Number(walletId), + send: protocol.send, + name: protocol.name + } + }, + update: { + enabled, + [relation]: { + update: dataFragment(args, 'update') + } + }, + create: { + enabled, + send: protocol.send, + name: protocol.name, + [relation]: { + create: dataFragment(args, 'create') + } + } + } + } + }, + include: { + protocols: true + } + }) + // XXX Prisma seems to run the vault update AFTER the update of the table that points to it + // which means our trigger to set the jsonb column in the WalletProtocol table does not see + // the updated vault entry. + // To fix this, we run another update to force the trigger to run again. + // TODO(wallet-v2): fix this in a better way? + await tx.walletProtocol.update({ + where: { + WalletProtocol_walletId_send_name_key: { + walletId: Number(walletId), + send: protocol.send, + name: protocol.name + } + }, + data: { + [relation]: { + update: { + updatedAt: new Date() + } + } + } + }) + + await updateWalletBadges({ userId: me.id, tx }) + + return mapWalletResolveTypes(wallet) + } + + return await (tx ? transaction(tx) : models.$transaction(transaction)) + } +} + +export async function removeWalletProtocol (parent, { id }, { me, models, tx }) { + if (!me) { + throw new GqlAuthenticationError() + } + + async function transaction (tx) { + // vault is deleted via trigger + const protocol = await tx.walletProtocol.delete({ + where: { + id: Number(id), + wallet: { + userId: me.id + } + } + }) + + const wallet = await tx.wallet.findUnique({ + where: { + id: protocol.walletId + }, + include: { + protocols: true + } + }) + if (wallet.protocols.length === 0) { + await tx.wallet.delete({ + where: { + id: wallet.id + } + }) + } + + await updateWalletBadges({ userId: me.id, tx }) + + return true + } + + return await (tx ? transaction(tx) : models.$transaction(transaction)) +} + +async function walletLogs (parent, { protocolId, cursor }, { me, models }) { + if (!me) throw new GqlAuthenticationError() + + const decodedCursor = decodeCursor(cursor) + + const logs = await models.walletLog.findMany({ + where: { + userId: me.id, + protocolId, + createdAt: { + lt: decodedCursor.time + } + }, + orderBy: { + createdAt: 'desc' + }, + take: LIMIT, + skip: decodedCursor.offset, + include: { + protocol: { + include: { + wallet: { + include: { + template: true + } + } + } + }, + invoice: true, + withdrawal: true + } + }) + + return { + entries: logs.map(log => ({ + ...log, + ...(log.protocol + ? { + wallet: { + ...log.protocol.wallet, + name: log.protocol.wallet.template.name + } + } + : {}) + })), + cursor: logs.length === LIMIT ? nextCursorEncoded(decodedCursor, LIMIT) : null + } +} + +async function addWalletLog (parent, { protocolId, level, message, timestamp, invoiceId }, { me, models }) { + if (!me) throw new GqlAuthenticationError() + + const logger = walletLogger({ models, protocolId, userId: me.id, invoiceId }) + await logger[level.toLowerCase()](message, { createdAt: timestamp }) + + return true +} + +async function deleteWalletLogs (parent, { protocolId }, { me, models }) { + if (!me) throw new GqlAuthenticationError() + + await models.walletLog.deleteMany({ + where: { + userId: me.id, + protocolId + } + }) + + return true +} + +async function updateWalletBadges ({ userId, tx }) { + const pushNotifications = [] + + const wallets = await tx.wallet.findMany({ + where: { + userId + }, + include: { + protocols: true + } + }) + + const { hasRecvWallet: oldHasRecvWallet, hasSendWallet: oldHasSendWallet } = await tx.user.findUnique({ where: { id: userId } }) + + const newHasRecvWallet = wallets.some(({ protocols }) => protocols.some(({ send, enabled }) => !send && enabled)) + const newHasSendWallet = wallets.some(({ protocols }) => protocols.some(({ send, enabled }) => send && enabled)) + + await tx.user.update({ + where: { id: userId }, + data: { + hasRecvWallet: newHasRecvWallet, + hasSendWallet: newHasSendWallet + } + }) + + const startStreak = async (type) => { + const streak = await tx.streak.create({ + data: { userId, type, startedAt: new Date() } + }) + return streak.id + } + + const endStreak = async (type) => { + const [streak] = await tx.$queryRaw` + UPDATE "Streak" + SET "endedAt" = now(), updated_at = now() + WHERE "userId" = ${userId} + AND "type" = ${type}::"StreakType" + AND "endedAt" IS NULL + RETURNING "id" + ` + return streak?.id + } + + if (!oldHasRecvWallet && newHasRecvWallet) { + const streakId = await startStreak('HORSE') + if (streakId) pushNotifications.push(() => notifyNewStreak(userId, { type: 'HORSE', id: streakId })) + } + if (!oldHasSendWallet && newHasSendWallet) { + const streakId = await startStreak('GUN') + if (streakId) pushNotifications.push(() => notifyNewStreak(userId, { type: 'GUN', id: streakId })) + } + + if (oldHasRecvWallet && !newHasRecvWallet) { + const streakId = await endStreak('HORSE') + if (streakId) pushNotifications.push(() => notifyStreakLost(userId, { type: 'HORSE', id: streakId })) + } + if (oldHasSendWallet && !newHasSendWallet) { + const streakId = await endStreak('GUN') + if (streakId) pushNotifications.push(() => notifyStreakLost(userId, { type: 'GUN', id: streakId })) + } + + // run all push notifications at the end to make sure we don't + // accidentally send push notifications even if transaction fails + Promise.all(pushNotifications.map(notify => notify())).catch(console.error) +} diff --git a/wallets/server/resolvers/util.js b/wallets/server/resolvers/util.js new file mode 100644 index 00000000..0155a422 --- /dev/null +++ b/wallets/server/resolvers/util.js @@ -0,0 +1,40 @@ +export function mapWalletResolveTypes (wallet) { + const resolveTypeOfProtocolConfig = ({ name, send }) => { + switch (name) { + case 'NWC': + return send ? 'WalletSendNWC' : 'WalletRecvNWC' + case 'LNBITS': + return send ? 'WalletSendLNbits' : 'WalletRecvLNbits' + case 'PHOENIXD': + return send ? 'WalletSendPhoenixd' : 'WalletRecvPhoenixd' + case 'BLINK': + return send ? 'WalletSendBlink' : 'WalletRecvBlink' + case 'WEBLN': + return 'WalletSendWebLN' + case 'LN_ADDR': + return 'WalletRecvLightningAddress' + case 'LNC': + return 'WalletSendLNC' + case 'CLN_REST': + return 'WalletRecvCLNRest' + case 'LND_GRPC': + return 'WalletRecvLNDGRPC' + default: + return null + } + } + + return { + ...wallet, + protocols: wallet.protocols.map(({ config, ...p }) => { + return { + ...p, + config: { + ...config, + __resolveType: resolveTypeOfProtocolConfig(p) + } + } + }), + __resolveType: 'Wallet' + } +} diff --git a/wallets/server/resolvers/wallet.js b/wallets/server/resolvers/wallet.js new file mode 100644 index 00000000..54f4bdf7 --- /dev/null +++ b/wallets/server/resolvers/wallet.js @@ -0,0 +1,227 @@ +import { GqlAuthenticationError, GqlInputError } from '@/lib/error' +import { mapWalletResolveTypes } from '@/wallets/server/resolvers/util' +import { removeWalletProtocol, upsertWalletProtocol } from './protocol' +import { validateSchema, walletSettingsSchema } from '@/lib/validate' + +const WalletOrTemplate = { + __resolveType: walletOrTemplate => walletOrTemplate.__resolveType +} + +const Wallet = { + name: wallet => wallet.template.name, + send: wallet => walletStatus(wallet, 'send'), + receive: wallet => walletStatus(wallet, 'receive') +} + +const WalletTemplate = { + send: walletTemplate => walletTemplate.sendProtocols.length > 0 ? 'OK' : 'DISABLED', + receive: walletTemplate => walletTemplate.recvProtocols.length > 0 ? 'OK' : 'DISABLED', + protocols: walletTemplate => { + return [ + ...walletTemplate.sendProtocols.map(protocol => ({ + id: `WalletTemplate-${walletTemplate.id}-${protocol}-send`, + name: protocol, + send: true + })), + ...walletTemplate.recvProtocols.map(protocol => ({ + id: `WalletTemplate-${walletTemplate.id}-${protocol}-recv`, + name: protocol, + send: false + })) + ] + } +} + +export const resolvers = { + WalletOrTemplate, + Wallet, + WalletTemplate, + Query: { + wallets, + wallet, + walletSettings + }, + Mutation: { + updateWalletEncryption, + updateKeyHash, + resetWallets, + setWalletPriorities, + disablePassphraseExport, + setWalletSettings + } +} + +async function wallets (parent, args, { me, models }) { + if (!me) { + throw new GqlAuthenticationError() + } + + let wallets = await models.wallet.findMany({ + where: { + userId: me.id + }, + include: { + template: true, + protocols: true + }, + orderBy: [ + { priority: 'asc' }, + { id: 'asc' } + ] + }) + + let walletTemplates = await models.walletTemplate.findMany() + + wallets = wallets.map(mapWalletResolveTypes) + walletTemplates = walletTemplates.map(t => { + return { + ...t, + __resolveType: 'WalletTemplate' + } + }) + + return [...wallets, ...walletTemplates] +} + +async function wallet (parent, { id, name }, { me, models }) { + if (!me) { + throw new GqlAuthenticationError() + } + + if (id) { + const wallet = await models.wallet.findUnique({ + where: { id: Number(id), userId: me.id }, + include: { + template: true, + protocols: true + } + }) + return mapWalletResolveTypes(wallet) + } + + const template = await models.walletTemplate.findUnique({ where: { name } }) + return { ...template, __resolveType: 'WalletTemplate' } +} + +function walletStatus (wallet, type) { + const protocols = wallet.protocols.filter(protocol => type === 'send' ? protocol.send : !protocol.send) + + const disabled = protocols.every(protocol => !protocol.enabled) + if (disabled) return 'DISABLED' + + const ok = protocols.every(protocol => protocol.status === 'OK') + if (ok) return 'OK' + + const error = protocols.every(protocol => protocol.status === 'ERROR') + if (error) return 'ERROR' + + return 'WARNING' +} + +async function walletSettings (parent, args, { me, models }) { + if (!me) throw new GqlAuthenticationError() + + return await models.user.findUnique({ where: { id: me.id } }) +} + +async function updateWalletEncryption (parent, { keyHash, wallets }, { me, models }) { + if (!me) throw new GqlAuthenticationError() + if (!keyHash) throw new GqlInputError('hash required') + + const { vaultKeyHash: oldKeyHash } = await models.user.findUnique({ where: { id: me.id } }) + + return await models.$transaction(async tx => { + for (const { id: walletId, protocols } of wallets) { + for (const { name, send, config } of protocols) { + const mutation = upsertWalletProtocol({ name, send }) + await mutation(parent, { walletId, networkTests: false, ignoreKeyHash: true, ...config }, { me, models: tx, tx }) + } + } + + // optimistic concurrency control: + // make sure the user's vault key didn't change while we were updating the protocols + await tx.user.update({ + where: { id: me.id, vaultKeyHash: oldKeyHash }, + data: { vaultKeyHash: keyHash, showPassphrase: false } + }) + + return true + }) +} + +async function updateKeyHash (parent, { keyHash }, { me, models }) { + if (!me) throw new GqlAuthenticationError() + + const count = await models.$executeRaw` + UPDATE users + SET "vaultKeyHash" = ${keyHash} + WHERE id = ${me.id} + AND "vaultKeyHash" = '' + ` + + return count > 0 +} + +async function resetWallets (parent, { newKeyHash }, { me, models }) { + if (!me) throw new GqlAuthenticationError() + + const { vaultKeyHash: oldHash } = await models.user.findUnique({ where: { id: me.id } }) + + await models.$transaction(async tx => { + const protocols = await tx.walletProtocol.findMany({ + where: { + send: true, + wallet: { + userId: me.id + } + } + }) + + for (const protocol of protocols) { + await removeWalletProtocol(parent, { id: protocol.id }, { me, tx }) + } + + await tx.user.update({ + where: { id: me.id, vaultKeyHash: oldHash }, + // TODO(wallet-v2): nullable vaultKeyHash column + data: { vaultKeyHash: newKeyHash, showPassphrase: true } + }) + }) + + return true +} + +async function disablePassphraseExport (parent, args, { me, models }) { + if (!me) throw new GqlAuthenticationError() + + await models.user.update({ where: { id: me.id }, data: { showPassphrase: false } }) + + return true +} + +async function setWalletPriorities (parent, { priorities }, { me, models }) { + if (!me) { + throw new GqlAuthenticationError() + } + + await models.$transaction(async tx => { + for (const { id, priority } of priorities) { + await tx.wallet.update({ + where: { userId: me.id, id: Number(id) }, + data: { priority } + }) + } + }) + + return true +} + +async function setWalletSettings (parent, { settings }, { me, models }) { + if (!me) throw new GqlAuthenticationError() + + await validateSchema(walletSettingsSchema, settings) + + await models.user.update({ where: { id: me.id }, data: settings }) + + return true +} diff --git a/wallets/wrap.js b/wallets/server/wrap.js similarity index 97% rename from wallets/wrap.js rename to wallets/server/wrap.js index a5079a10..c43b1a21 100644 --- a/wallets/wrap.js +++ b/wallets/server/wrap.js @@ -1,5 +1,5 @@ import { createHodlInvoice, parsePaymentRequest } from 'ln-service' -import { estimateRouteFee, getBlockHeight } from '../api/lnd' +import { estimateRouteFee, getBlockHeight } from '@/api/lnd' import { toBigInt, toPositiveBigInt, toPositiveNumber } from '@/lib/format' const MIN_OUTGOING_MSATS = BigInt(700) // the minimum msats we'll allow for the outgoing invoice @@ -28,7 +28,7 @@ const MAX_FEE_ESTIMATE_PERCENT = 3n // the maximum fee relative to outgoing we'l maxFee: number } */ -export default async function wrapInvoice ({ bolt11, feePercent }, { msats, description, descriptionHash }, { me, lnd }) { +export async function wrapInvoice ({ bolt11, feePercent }, { msats, description, descriptionHash }, { me, lnd }) { try { console.group('wrapInvoice', description) diff --git a/wallets/status.js b/wallets/status.js deleted file mode 100644 index dc92cfd7..00000000 --- a/wallets/status.js +++ /dev/null @@ -1,54 +0,0 @@ -import { canReceive, canSend, isConfigured } from '@/wallets/common' -import { useWalletLogs } from '@/wallets/logger' -import styles from '@/styles/wallet.module.css' - -export const Status = { - Enabled: 'Enabled', - Disabled: 'Disabled', - Error: 'Error', - Warning: 'Warning' -} - -export function useWalletStatus (wallet) { - const { logs } = useWalletLogs(wallet) - - return statusFromLogs(wallet, { - any: wallet.config?.enabled && isConfigured(wallet) ? Status.Enabled : Status.Disabled, - send: wallet.config?.enabled && canSend(wallet) ? Status.Enabled : Status.Disabled, - recv: wallet.config?.enabled && canReceive(wallet) ? Status.Enabled : Status.Disabled - }, logs) -} - -const statusFromLogs = (wallet, status, logs) => { - if (status.any === Status.Disabled) return status - - // override status depending on if there have been warnings or errors in the logs recently - // find first log from which we can derive status (logs are sorted by recent first) - const walletLogs = logs.filter(l => l.wallet === wallet.def.name) - const sendLevel = walletLogs.find(l => l.context?.status && l.context?.send)?.level - const recvLevel = walletLogs.find(l => l.context?.status && l.context?.recv)?.level - - const levelToStatus = (level) => { - switch (level?.toLowerCase()) { - case 'ok': - case 'success': return Status.Enabled - case 'error': return Status.Error - case 'warn': return Status.Warning - } - } - - return { - any: status.any, - send: levelToStatus(sendLevel) || status.send, - recv: levelToStatus(recvLevel) || status.recv - } -} - -export const statusToClass = status => { - switch (status) { - case Status.Enabled: return styles.success - case Status.Disabled: return styles.disabled - case Status.Error: return styles.error - case Status.Warning: return styles.warning - } -} diff --git a/wallets/support.js b/wallets/support.js deleted file mode 100644 index 270d35c2..00000000 --- a/wallets/support.js +++ /dev/null @@ -1,8 +0,0 @@ -import { supportsReceive, supportsSend } from '@/wallets/common' - -export function useWalletSupport (wallet) { - return { - send: supportsSend(wallet), - recv: supportsReceive(wallet) - } -} diff --git a/wallets/validate.js b/wallets/validate.js deleted file mode 100644 index bea0cddd..00000000 --- a/wallets/validate.js +++ /dev/null @@ -1,117 +0,0 @@ -/* - we want to take all the validate members from the provided wallet - and compose into a single yup schema for formik validation ... - the validate member can be on of: - - a yup schema - - a function that throws on an invalid value - - a regular expression that must match -*/ - -import { autowithdrawSchemaMembers, vaultEntrySchema } from '@/lib/validate' -import * as Yup from '@/lib/yup' -import { canReceive } from './common' - -export default async function validateWallet (walletDef, data, - { yupOptions = { abortEarly: true }, topLevel = true, serverSide = false, skipGenerated = false } = {}) { - let schema = composeWalletSchema(walletDef, serverSide, skipGenerated) - - if (canReceive({ def: walletDef, config: data })) { - schema = schema.concat(autowithdrawSchemaMembers) - } - - await schema.validate(data, yupOptions) - - const casted = schema.cast(data, { assert: false }) - if (topLevel && walletDef.validate) { - await walletDef.validate(casted) - } - - return casted -} - -function createFieldSchema (name, validate) { - if (!validate) { - throw new Error(`No validation provided for field ${name}`) - } - - if (Yup.isSchema(validate)) { - // If validate is already a Yup schema, return it directly - return validate - } else if (typeof validate === 'function') { - // If validate is a function, create a custom Yup test - return Yup.mixed().test({ - name, - test: (value, context) => { - try { - validate(value) - return true - } catch (error) { - return context.createError({ message: error.message }) - } - } - }) - } else if (validate instanceof RegExp) { - // If validate is a regular expression, use Yup.matches - return Yup.string().matches(validate, `${name} is invalid`) - } else { - throw new Error(`validate for ${name} must be a yup schema, function, or regular expression`) - } -} - -function composeWalletSchema (walletDef, serverSide, skipGenerated) { - const { fields } = walletDef - - const vaultEntrySchemas = { required: [], optional: [] } - const cycleBreaker = [] - const schemaShape = fields.reduce((acc, field) => { - const { name, validate, optional, generated, clientOnly, requiredWithout } = field - - if (generated && skipGenerated) { - return acc - } - - if (clientOnly && serverSide) { - // For server-side validation, accumulate clientOnly fields as vaultEntries - vaultEntrySchemas[optional ? 'optional' : 'required'].push(vaultEntrySchema(name)) - } else { - acc[name] = createFieldSchema(name, validate) - - if (!optional) { - acc[name] = acc[name].required('required') - } else if (requiredWithout) { - const myName = serverSide ? 'vaultEntries' : name - const partnerName = serverSide ? 'vaultEntries' : requiredWithout - // if a cycle breaker between this pair hasn't been added yet, add it - if (!cycleBreaker.some(pair => pair[1] === myName)) { - cycleBreaker.push([myName, partnerName]) - } - // if we are the server, the pairSetting will be in the vaultEntries array - acc[name] = acc[name].when([partnerName], ([pairSetting], schema) => { - if (!pairSetting || (serverSide && !pairSetting.some(v => v.key === requiredWithout))) { - return schema.required(`required if ${requiredWithout} not set`) - } - return Yup.mixed().or([schema.test({ - test: value => value !== pairSetting, - message: `${name} cannot be the same as ${requiredWithout}` - }), Yup.mixed().notRequired()]) - }) - } - } - - return acc - }, {}) - - // Finalize the vaultEntries schema if it exists - if (vaultEntrySchemas.required.length > 0 || vaultEntrySchemas.optional.length > 0) { - schemaShape.vaultEntries = Yup.array().equalto(vaultEntrySchemas) - } - - // we use cycleBreaker to avoid cyclic dependencies in Yup schema - // see https://github.com/jquense/yup/issues/176#issuecomment-367352042 - const composedSchema = Yup.object().shape(schemaShape, cycleBreaker).concat(Yup.object({ - enabled: Yup.boolean(), - priority: Yup.number().min(0, 'must be at least 0').max(100, 'must be at most 100') - })) - - return composedSchema -} diff --git a/wallets/webln/client.js b/wallets/webln/client.js deleted file mode 100644 index a54df4b3..00000000 --- a/wallets/webln/client.js +++ /dev/null @@ -1,57 +0,0 @@ -import { useEffect } from 'react' -import { SSR } from '@/lib/constants' -import { WalletError } from '../errors' -export * from '@/wallets/webln' - -export const sendPayment = async (bolt11) => { - if (typeof window.webln === 'undefined') { - throw new WalletError('WebLN provider not found') - } - - // this will prompt the user to unlock the wallet if it's locked - try { - await window.webln.enable() - } catch (err) { - throw new WalletError(err.message) - } - - // this will prompt for payment if no budget is set - const response = await window.webln.sendPayment(bolt11) - if (!response) { - // sendPayment returns nothing if WebLN was enabled - // but browser extension that provides WebLN was then disabled - // without reloading the page - throw new WalletError('sendPayment returned no response') - } - - return response.preimage -} - -export function isAvailable () { - return !SSR && window?.weblnEnabled -} - -export function WebLnProvider ({ children }) { - useEffect(() => { - const onEnable = () => { - window.weblnEnabled = true - } - - const onDisable = () => { - window.weblnEnabled = false - } - - if (!window.webln) onDisable() - else onEnable() - - window.addEventListener('webln:enabled', onEnable) - // event is not fired by Alby browser extension but added here for sake of completeness - window.addEventListener('webln:disabled', onDisable) - return () => { - window.removeEventListener('webln:enabled', onEnable) - window.removeEventListener('webln:disabled', onDisable) - } - }, []) - - return children -} diff --git a/wallets/webln/index.js b/wallets/webln/index.js deleted file mode 100644 index cce91750..00000000 --- a/wallets/webln/index.js +++ /dev/null @@ -1,16 +0,0 @@ -export const name = 'webln' -export const walletType = 'WEBLN' -export const walletField = 'walletWebLN' - -export const validate = ({ enabled }) => { - if (enabled && typeof window !== 'undefined' && !window?.webln) { - throw new Error('no WebLN provider found') - } -} - -export const fields = [] - -export const card = { - title: 'WebLN', - subtitle: 'use a [WebLN provider](https://www.webln.guide/ressources/webln-providers) for payments' -} diff --git a/worker/autowithdraw.js b/worker/autowithdraw.js index 6d987e76..a87c7a9d 100644 --- a/worker/autowithdraw.js +++ b/worker/autowithdraw.js @@ -42,7 +42,7 @@ export async function autoWithdraw ({ data: { id }, models, lnd }) { if (pendingOrFailed.exists) return - for await (const { invoice, wallet, logger } of createUserInvoice(id, { + for await (const { invoice, protocol, logger } of createUserInvoice(id, { msats, description: 'SN: autowithdrawal', expiry: 360 @@ -50,10 +50,10 @@ export async function autoWithdraw ({ data: { id }, models, lnd }) { try { return await createWithdrawal(null, { invoice, maxFee: msatsToSats(maxFeeMsats) }, - { me: { id }, models, lnd, wallet, logger }) + { me: { id }, models, lnd, protocol, logger }) } catch (err) { console.error('failed to create autowithdrawal:', err) - logger?.error('incoming payment failed: ' + err.message, { bolt11: invoice }) + logger?.warn('incoming payment failed: ' + err.message, { bolt11: invoice }) } } diff --git a/worker/index.js b/worker/index.js index 03b1a3f4..20848e23 100644 --- a/worker/index.js +++ b/worker/index.js @@ -4,8 +4,7 @@ import PgBoss from 'pg-boss' import createPrisma from '@/lib/create-prisma' import { checkInvoice, checkPendingDeposits, checkPendingWithdrawals, - checkWithdrawal, checkWallet, - finalizeHodlInvoice, subscribeToWallet + checkWithdrawal, finalizeHodlInvoice, subscribeToWallet } from './wallet' import { repin } from './repin' import { trust } from './trust' @@ -144,7 +143,6 @@ async function work () { await boss.work('reminder', jobWrapper(remindUser)) await boss.work('thisDay', jobWrapper(thisDay)) await boss.work('socialPoster', jobWrapper(postToSocial)) - await boss.work('checkWallet', jobWrapper(checkWallet)) console.log('working jobs') } diff --git a/worker/paidAction.js b/worker/paidAction.js index d628e3fd..d24e7a27 100644 --- a/worker/paidAction.js +++ b/worker/paidAction.js @@ -1,6 +1,6 @@ import { getPaymentFailureStatus, hodlInvoiceCltvDetails, getPaymentOrNotSent } from '@/api/lnd' import { paidActions } from '@/api/paidAction' -import { walletLogger } from '@/api/resolvers/wallet' +import { walletLogger } from '@/wallets/server/logger' import { LND_PATHFINDING_TIME_PREF_PPM, LND_PATHFINDING_TIMEOUT_MS, PAID_ACTION_TERMINAL_STATES } from '@/lib/constants' import { formatSats, msatsToSats, toPositiveNumber } from '@/lib/format' import { datePivot } from '@/lib/time' @@ -10,7 +10,7 @@ import { getInvoice, parsePaymentRequest, payViaPaymentRequest, settleHodlInvoice } from 'ln-service' -import { MIN_SETTLEMENT_CLTV_DELTA } from '@/wallets/wrap' +import { MIN_SETTLEMENT_CLTV_DELTA } from '@/wallets/server/wrap' // aggressive finalization retry options const FINALIZE_OPTIONS = { retryLimit: 2 ** 31 - 1, retryBackoff: false, retryDelay: 5, priority: 1000 } @@ -44,7 +44,11 @@ async function transitionInvoice (jobName, include: { invoice: true, withdrawl: true, - wallet: true + protocol: { + include: { + wallet: true + } + } } } } @@ -234,8 +238,8 @@ export async function paidActionForwarding ({ data: { invoiceId, ...args }, mode msatsPaying: BigInt(invoice.mtokens), msatsFeePaying: maxFeeMsats, autoWithdraw: true, - walletId: invoiceForward.walletId, - userId: invoiceForward.wallet.userId + protocolId: invoiceForward.protocolId, + userId: invoiceForward.protocol.wallet.userId } } } @@ -309,14 +313,15 @@ export async function paidActionForwarded ({ data: { invoiceId, withdrawal, ...a }, { models, lnd, boss }) if (transitionedInvoice) { - const withdrawal = transitionedInvoice.invoiceForward.withdrawl - - const logger = walletLogger({ wallet: transitionedInvoice.invoiceForward.wallet, models }) - logger.ok( - `↙ payment received: ${formatSats(msatsToSats(Number(withdrawal.msatsPaid)))}`, { - invoiceId: transitionedInvoice.id, - withdrawalId: withdrawal.id - }) + const { withdrawl, protocol } = transitionedInvoice.invoiceForward + const logger = walletLogger({ + models, + protocolId: protocol.id, + userId: protocol.wallet.userId, + invoiceId: transitionedInvoice.id, + withdrawalId: withdrawl.id + }) + logger.ok(`↙ payment received: ${formatSats(msatsToSats(Number(withdrawl.msatsPaid)))}`) } return transitionedInvoice @@ -364,12 +369,15 @@ export async function paidActionFailedForward ({ data: { invoiceId, withdrawal: }, { models, lnd, boss }) if (transitionedInvoice) { - const fwd = transitionedInvoice.invoiceForward - const logger = walletLogger({ wallet: fwd.wallet, models }) - logger.warn( - `incoming payment failed: ${message}`, { - withdrawalId: fwd.withdrawl.id - }) + const { withdrawl, protocol } = transitionedInvoice.invoiceForward + const logger = walletLogger({ + models, + protocolId: protocol.id, + userId: protocol.wallet.userId, + invoiceId: transitionedInvoice.id, + withdrawalId: withdrawl.id + }) + logger.warn(`incoming payment failed: ${message}`) } return transitionedInvoice @@ -430,14 +438,15 @@ export async function paidActionCanceling ({ data: { invoiceId, ...args }, model if (transitionedInvoice) { if (transitionedInvoice.invoiceForward) { - const { wallet, bolt11 } = transitionedInvoice.invoiceForward - const logger = walletLogger({ wallet, models }) + const { protocol, bolt11 } = transitionedInvoice.invoiceForward + const logger = walletLogger({ + models, + protocolId: protocol.id, + userId: protocol.wallet.userId, + invoiceId: transitionedInvoice.id + }) const decoded = await parsePaymentRequest({ request: bolt11 }) - logger.info( - `invoice for ${formatSats(msatsToSats(decoded.mtokens))} canceled by payer`, { - bolt11, - invoiceId: transitionedInvoice.id - }) + logger.info(`invoice for ${formatSats(msatsToSats(decoded.mtokens))} canceled by payer`) } } diff --git a/worker/payingAction.js b/worker/payingAction.js index 4d868f1a..c35b8450 100644 --- a/worker/payingAction.js +++ b/worker/payingAction.js @@ -1,5 +1,5 @@ import { getPaymentFailureStatus, getPaymentOrNotSent } from '@/api/lnd' -import { walletLogger } from '@/api/resolvers/wallet' +import { walletLogger } from '@/wallets/server/logger' import { formatMsats, formatSats, msatsToSats, toPositiveBigInt } from '@/lib/format' import { datePivot } from '@/lib/time' import { notifyWithdrawal } from '@/lib/webPush' @@ -28,7 +28,7 @@ async function transitionWithdrawal (jobName, // grab optimistic concurrency lock and the withdrawal dbWithdrawal = await tx.withdrawl.update({ include: { - wallet: true + protocol: true }, where: { id: withdrawalId, @@ -49,7 +49,7 @@ async function transitionWithdrawal (jobName, if (data) { return await tx.withdrawl.update({ include: { - wallet: true + protocol: true }, where: { id: dbWithdrawal.id }, data @@ -123,11 +123,15 @@ export async function payingActionConfirmed ({ data: args, models, lnd, boss }) if (transitionedWithdrawal) { await notifyWithdrawal(transitionedWithdrawal) - const logger = walletLogger({ models, wallet: transitionedWithdrawal.wallet }) - logger?.ok( - `↙ payment received: ${formatSats(msatsToSats(transitionedWithdrawal.msatsPaid))}`, { - withdrawalId: transitionedWithdrawal.id - }) + const { protocol, userId } = transitionedWithdrawal + + const logger = walletLogger({ + models, + protocol, + userId, + withdrawalId: transitionedWithdrawal.id + }) + logger?.ok(`↙ payment received: ${formatSats(msatsToSats(transitionedWithdrawal.msatsPaid))}`) } } diff --git a/worker/wallet.js b/worker/wallet.js index ab504d08..22f537ed 100644 --- a/worker/wallet.js +++ b/worker/wallet.js @@ -12,8 +12,6 @@ import { paidActionCanceling } from './paidAction' import { payingActionConfirmed, payingActionFailed } from './payingAction' -import { canReceive, getWalletByType } from '@/wallets/common' -import { notifyNewStreak, notifyStreakLost } from '@/lib/webPush' export async function subscribeToWallet (args) { await subscribeToDeposits(args) @@ -212,7 +210,6 @@ export async function checkWithdrawal ({ data: { hash, withdrawal, invoice }, bo ] }, include: { - wallet: true, invoiceForward: { include: { invoice: true @@ -286,73 +283,3 @@ export async function checkPendingWithdrawals (args) { } } } - -export async function checkWallet ({ data: { userId }, models }) { - const pushNotifications = [] - - await models.$transaction(async tx => { - const wallets = await tx.wallet.findMany({ - where: { - userId, - enabled: true - }, - include: { - vaultEntries: true - } - }) - - const { hasRecvWallet: oldHasRecvWallet, hasSendWallet: oldHasSendWallet } = await tx.user.findUnique({ where: { id: userId } }) - - const newHasRecvWallet = wallets.some(({ type, wallet }) => canReceive({ def: getWalletByType(type), config: wallet })) - const newHasSendWallet = wallets.some(({ vaultEntries }) => vaultEntries.length > 0) - - await tx.user.update({ - where: { id: userId }, - data: { - hasRecvWallet: newHasRecvWallet, - hasSendWallet: newHasSendWallet - } - }) - - const startStreak = async (type) => { - const streak = await tx.streak.create({ - data: { userId, type, startedAt: new Date() } - }) - return streak.id - } - - const endStreak = async (type) => { - const [streak] = await tx.$queryRaw` - UPDATE "Streak" - SET "endedAt" = now(), updated_at = now() - WHERE "userId" = ${userId} - AND "type" = ${type}::"StreakType" - AND "endedAt" IS NULL - RETURNING "id" - ` - return streak?.id - } - - if (!oldHasRecvWallet && newHasRecvWallet) { - const streakId = await startStreak('HORSE') - if (streakId) pushNotifications.push(() => notifyNewStreak(userId, { type: 'HORSE', id: streakId })) - } - if (!oldHasSendWallet && newHasSendWallet) { - const streakId = await startStreak('GUN') - if (streakId) pushNotifications.push(() => notifyNewStreak(userId, { type: 'GUN', id: streakId })) - } - - if (oldHasRecvWallet && !newHasRecvWallet) { - const streakId = await endStreak('HORSE') - if (streakId) pushNotifications.push(() => notifyStreakLost(userId, { type: 'HORSE', id: streakId })) - } - if (oldHasSendWallet && !newHasSendWallet) { - const streakId = await endStreak('GUN') - if (streakId) pushNotifications.push(() => notifyStreakLost(userId, { type: 'GUN', id: streakId })) - } - }) - - // run all push notifications at the end to make sure we don't - // accidentally send duplicate push notifications because of a job retry - await Promise.all(pushNotifications.map(notify => notify())).catch(console.error) -}