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 (
- <>
-
- >
-
- )
-}
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.
-
-
-
- )
-}
-
-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.
-
-
-
- )
-}
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}
-
-
- {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 (
+
+
+ unlock wallets
+
+ 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 (
+
setShowWallets(true)}
@@ -100,70 +79,54 @@ export default function Wallet ({ ssrData }) {
attach a wallet to send and receive sats
-
+
)
}
return (
-
-
-
wallets
-
use real bitcoin
+
+
+
wallets
+
use real bitcoin
-
- wallet logs
-
+ wallet logs
+ •
+ settings
+ {showPassphrase && (
+ <>
+ •
+
+ passphrase
+
+ >
+ )}
-
-
- 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
+
+
+
+ )
+}
+
+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 &&
-
{deleteText} }
- {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 (
+
+
+
+ SEND
+
+
+
+
+ RECEIVE
+
+
+
+ )
+}
+
+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 (
+
+ {
+ protocols.map(p => (
+
+
+ {protocolDisplayName(p)}
+
+
+ ))
+ }
+
+ )
+}
+
+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 (
+ <>
+
+
+ >
+ )
+}
+
+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) && detach }
+ 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
+ ? (
+
+ )
+ : 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
+ ?
more
+ :
------ 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?
+
+
+ cancel
+ yes, show me
+
+
+ ))
+ }, [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.
+
+
+ cancel
+ reset
+
+
+ ))
+ }, [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.
+
+
+
+ ))
+ }, [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
+ delete
+
+
+
+ )
+}
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 }) => {