Compare commits
113 Commits
master
...
wallet-int
Author | SHA1 | Date |
---|---|---|
ekzyis | 1b6de0bb96 | |
keyan | a0c1d4f602 | |
ekzyis | 5d03e08514 | |
ekzyis | 6a5713034b | |
ekzyis | c8d91bf42d | |
ekzyis | 08a5ce1a28 | |
ekzyis | 4df0b460c3 | |
ekzyis | 587bfa34be | |
ekzyis | 3933a4f460 | |
ekzyis | 667cde6042 | |
ekzyis | 6432ea7b44 | |
ekzyis | fb2b34ce67 | |
ekzyis | 9587ff9a52 | |
ekzyis | 538f1e21d6 | |
ekzyis | e25a3dbec0 | |
Keyan | 128f1f93b8 | |
ekzyis | b777fdcddc | |
ekzyis | bbcfc2fada | |
ekzyis | 5b2e835722 | |
ekzyis | 259ebef971 | |
ekzyis | 7851366cd5 | |
ekzyis | cba76444dd | |
ekzyis | f01ce79afa | |
ekzyis | 03ca84629b | |
ekzyis | 7749c14d3b | |
ekzyis | ee1574cf45 | |
ekzyis | 6ac675429c | |
keyan | c767e106a0 | |
ekzyis | 6e6af40eb9 | |
ekzyis | 05c0f8a66e | |
ekzyis | 80756f23a4 | |
ekzyis | 24bdf0a099 | |
ekzyis | d9205b6d30 | |
ekzyis | 7402885998 | |
ekzyis | 1a60f13d72 | |
ekzyis | 920478a72c | |
ekzyis | 9af8e63355 | |
ekzyis | 8a36bffb85 | |
ekzyis | 8ea4d0c8a7 | |
ekzyis | 2051dd0e88 | |
ekzyis | 5d678ced23 | |
ekzyis | 459478036f | |
ekzyis | a69bca0f05 | |
ekzyis | 85cfda330b | |
ekzyis | 85464f93b9 | |
ekzyis | dddbb53792 | |
ekzyis | ebe741dc92 | |
ekzyis | 6bee659f2f | |
ekzyis | bd0e4d906c | |
ekzyis | 7528e5c2b6 | |
ekzyis | 1ce09051b1 | |
ekzyis | 8dac53d7d5 | |
ekzyis | cd074a47b7 | |
ekzyis | 12bedae01a | |
ekzyis | b569c8faa0 | |
ekzyis | ba00c3d9fa | |
ekzyis | 00f78daadc | |
ekzyis | 0a0085fe82 | |
ekzyis | 48ead97615 | |
ekzyis | 6463e6eec8 | |
ekzyis | 0ebe097a70 | |
ekzyis | 850c534c91 | |
ekzyis | 83fd39b035 | |
ekzyis | 9bbf2056e9 | |
ekzyis | 8acf74c787 | |
ekzyis | 55928ac252 | |
ekzyis | c270805649 | |
ekzyis | eb2f4b980f | |
ekzyis | b96757b366 | |
ekzyis | 39d8928772 | |
ekzyis | da6d262e0a | |
ekzyis | d20e258649 | |
ekzyis | d60e26bfdf | |
ekzyis | 9509833b88 | |
ekzyis | 645ff78365 | |
ekzyis | c18263dc73 | |
ekzyis | d8e82ddea5 | |
ekzyis | e091377d94 | |
ekzyis | 5b561e22a9 | |
ekzyis | 4bf9954c4e | |
ekzyis | 3b0605a691 | |
ekzyis | 1f98a1a891 | |
ekzyis | 377ac04c85 | |
ekzyis | 9228328d3b | |
ekzyis | 2aa0c9bc99 | |
ekzyis | d7c81cfa9f | |
ekzyis | 4a16cc17aa | |
ekzyis | 4082a45618 | |
ekzyis | ae0335d537 | |
ekzyis | 91978171ed | |
ekzyis | dae69ec4b3 | |
ekzyis | eda7fd6b46 | |
ekzyis | fd08356d37 | |
ekzyis | 61be80446d | |
ekzyis | 6059e8f691 | |
ekzyis | 1bae891594 | |
ekzyis | 276e734a7a | |
ekzyis | 7b6602e386 | |
ekzyis | 8e2dd45e23 | |
ekzyis | 7639390a16 | |
ekzyis | 29646eb956 | |
ekzyis | dd47f2c02b | |
ekzyis | a5ea53dc39 | |
ekzyis | 399c62a7e3 | |
ekzyis | 034cb4e8b2 | |
ekzyis | b8b0a4f985 | |
ekzyis | 0957cb5b83 | |
ekzyis | 71c753810c | |
ekzyis | 0de82db78a | |
ekzyis | 1a2be99027 | |
ekzyis | 6ac8785c51 | |
ekzyis | a1b343ac89 | |
ekzyis | 5f047cbfc9 |
|
@ -20,7 +20,6 @@ node_modules/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.pem
|
*.pem
|
||||||
/*.sql
|
/*.sql
|
||||||
lnbits/
|
|
||||||
|
|
||||||
# debug
|
# debug
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
|
|
@ -1,19 +1,40 @@
|
||||||
import { getIdentity, createHodlInvoice, createInvoice, decodePaymentRequest, payViaPaymentRequest, cancelHodlInvoice, getInvoice as getInvoiceFromLnd, getNode, authenticatedLndGrpc, deletePayment, getPayment } from 'ln-service'
|
import { createHodlInvoice, createInvoice, decodePaymentRequest, payViaPaymentRequest, cancelHodlInvoice, getInvoice as getInvoiceFromLnd, getNode, deletePayment, getPayment, getIdentity } from 'ln-service'
|
||||||
import { GraphQLError } from 'graphql'
|
import { GraphQLError } from 'graphql'
|
||||||
import crypto, { timingSafeEqual } from 'crypto'
|
import crypto, { timingSafeEqual } from 'crypto'
|
||||||
import serialize from './serial'
|
import serialize from './serial'
|
||||||
import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
|
import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
|
||||||
import { SELECT, itemQueryWithMeta } from './item'
|
import { SELECT, itemQueryWithMeta } from './item'
|
||||||
import { lnAddrOptions } from '@/lib/lnurl'
|
import { msatsToSats, msatsToSatsDecimal } from '@/lib/format'
|
||||||
import { msatsToSats, msatsToSatsDecimal, ensureB64 } from '@/lib/format'
|
import { amountSchema, ssValidate, withdrawlSchema, lnAddrSchema } from '@/lib/validate'
|
||||||
import { CLNAutowithdrawSchema, LNDAutowithdrawSchema, amountSchema, lnAddrAutowithdrawSchema, lnAddrSchema, ssValidate, withdrawlSchema } from '@/lib/validate'
|
import { ANON_BALANCE_LIMIT_MSATS, ANON_INV_PENDING_LIMIT, USER_ID, BALANCE_LIMIT_MSATS, INVOICE_RETENTION_DAYS, INV_PENDING_LIMIT, USER_IDS_BALANCE_NO_LIMIT } from '@/lib/constants'
|
||||||
import { ANON_BALANCE_LIMIT_MSATS, ANON_INV_PENDING_LIMIT, USER_ID, BALANCE_LIMIT_MSATS, INVOICE_RETENTION_DAYS, INV_PENDING_LIMIT, USER_IDS_BALANCE_NO_LIMIT, Wallet } from '@/lib/constants'
|
|
||||||
import { datePivot } from '@/lib/time'
|
import { datePivot } from '@/lib/time'
|
||||||
import assertGofacYourself from './ofac'
|
import assertGofacYourself from './ofac'
|
||||||
import assertApiKeyNotPermitted from './apiKey'
|
import assertApiKeyNotPermitted from './apiKey'
|
||||||
import { createInvoice as createInvoiceCLN } from '@/lib/cln'
|
|
||||||
import { bolt11Tags } from '@/lib/bolt11'
|
import { bolt11Tags } from '@/lib/bolt11'
|
||||||
import { checkInvoice } from 'worker/wallet'
|
import { checkInvoice } from 'worker/wallet'
|
||||||
|
import walletDefs from 'wallets/server'
|
||||||
|
import { generateResolverName, generateSchema } from '@/lib/wallet'
|
||||||
|
import { lnAddrOptions } from '@/lib/lnurl'
|
||||||
|
|
||||||
|
function injectResolvers (resolvers) {
|
||||||
|
console.group('injected GraphQL resolvers:')
|
||||||
|
for (const w of walletDefs) {
|
||||||
|
const { walletType, walletField, testConnect } = w
|
||||||
|
const resolverName = generateResolverName(walletField)
|
||||||
|
console.log(resolverName)
|
||||||
|
resolvers.Mutation[resolverName] = async (parent, { settings, ...data }, { me, models }) => {
|
||||||
|
return await upsertWallet({
|
||||||
|
schema: generateSchema(w),
|
||||||
|
wallet: { field: walletField, type: walletType },
|
||||||
|
testConnect: (data) =>
|
||||||
|
testConnect(data, { me, models })
|
||||||
|
}, { settings, data }, { me, models })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.groupEnd()
|
||||||
|
|
||||||
|
return resolvers
|
||||||
|
}
|
||||||
|
|
||||||
export async function getInvoice (parent, { id }, { me, models, lnd }) {
|
export async function getInvoice (parent, { id }, { me, models, lnd }) {
|
||||||
const inv = await models.invoice.findUnique({
|
const inv = await models.invoice.findUnique({
|
||||||
|
@ -93,7 +114,7 @@ export function createHmac (hash) {
|
||||||
return crypto.createHmac('sha256', key).update(Buffer.from(hash, 'hex')).digest('hex')
|
return crypto.createHmac('sha256', key).update(Buffer.from(hash, 'hex')).digest('hex')
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
const resolvers = {
|
||||||
Query: {
|
Query: {
|
||||||
invoice: getInvoice,
|
invoice: getInvoice,
|
||||||
wallet: async (parent, { id }, { me, models }) => {
|
wallet: async (parent, { id }, { me, models }) => {
|
||||||
|
@ -318,9 +339,10 @@ export default {
|
||||||
where: {
|
where: {
|
||||||
userId: me.id
|
userId: me.id
|
||||||
},
|
},
|
||||||
orderBy: {
|
orderBy: [
|
||||||
createdAt: 'asc'
|
{ createdAt: 'desc' },
|
||||||
}
|
{ id: 'desc' }
|
||||||
|
]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -423,85 +445,6 @@ export default {
|
||||||
}
|
}
|
||||||
return { id }
|
return { id }
|
||||||
},
|
},
|
||||||
upsertWalletLND: async (parent, { settings, ...data }, { me, models }) => {
|
|
||||||
// make sure inputs are base64
|
|
||||||
data.macaroon = ensureB64(data.macaroon)
|
|
||||||
data.cert = ensureB64(data.cert)
|
|
||||||
|
|
||||||
const wallet = Wallet.LND
|
|
||||||
return await upsertWallet(
|
|
||||||
{
|
|
||||||
schema: LNDAutowithdrawSchema,
|
|
||||||
wallet,
|
|
||||||
testConnect: async ({ cert, macaroon, socket }) => {
|
|
||||||
try {
|
|
||||||
const { lnd } = await authenticatedLndGrpc({
|
|
||||||
cert,
|
|
||||||
macaroon,
|
|
||||||
socket
|
|
||||||
})
|
|
||||||
const inv = await createInvoice({
|
|
||||||
description: 'SN connection test',
|
|
||||||
lnd,
|
|
||||||
tokens: 0,
|
|
||||||
expires_at: new Date()
|
|
||||||
})
|
|
||||||
// we wrap both calls in one try/catch since connection attempts happen on RPC calls
|
|
||||||
await addWalletLog({ wallet, level: 'SUCCESS', message: 'connected to LND' }, { me, models })
|
|
||||||
return inv
|
|
||||||
} catch (err) {
|
|
||||||
// LND errors are in this shape: [code, type, { err: { code, details, metadata } }]
|
|
||||||
const details = err[2]?.err?.details || err.message || err.toString?.()
|
|
||||||
await addWalletLog({ wallet, level: 'ERROR', message: `could not connect to LND: ${details}` }, { me, models })
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ settings, data }, { me, models })
|
|
||||||
},
|
|
||||||
upsertWalletCLN: async (parent, { settings, ...data }, { me, models }) => {
|
|
||||||
data.cert = ensureB64(data.cert)
|
|
||||||
|
|
||||||
const wallet = Wallet.CLN
|
|
||||||
return await upsertWallet(
|
|
||||||
{
|
|
||||||
schema: CLNAutowithdrawSchema,
|
|
||||||
wallet,
|
|
||||||
testConnect: async ({ socket, rune, cert }) => {
|
|
||||||
try {
|
|
||||||
const inv = await createInvoiceCLN({
|
|
||||||
socket,
|
|
||||||
rune,
|
|
||||||
cert,
|
|
||||||
description: 'SN connection test',
|
|
||||||
msats: 'any',
|
|
||||||
expiry: 0
|
|
||||||
})
|
|
||||||
await addWalletLog({ wallet, level: 'SUCCESS', message: 'connected to CLN' }, { me, models })
|
|
||||||
return inv
|
|
||||||
} catch (err) {
|
|
||||||
const details = err.details || err.message || err.toString?.()
|
|
||||||
await addWalletLog({ wallet, level: 'ERROR', message: `could not connect to CLN: ${details}` }, { me, models })
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ settings, data }, { me, models })
|
|
||||||
},
|
|
||||||
upsertWalletLNAddr: async (parent, { settings, ...data }, { me, models }) => {
|
|
||||||
const wallet = Wallet.LnAddr
|
|
||||||
return await upsertWallet(
|
|
||||||
{
|
|
||||||
schema: lnAddrAutowithdrawSchema,
|
|
||||||
wallet,
|
|
||||||
testConnect: async ({ address }) => {
|
|
||||||
const options = await lnAddrOptions(address)
|
|
||||||
await addWalletLog({ wallet, level: 'SUCCESS', message: 'fetched payment details' }, { me, models })
|
|
||||||
return options
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ settings, data }, { me, models })
|
|
||||||
},
|
|
||||||
removeWallet: async (parent, { id }, { me, models }) => {
|
removeWallet: async (parent, { id }, { me, models }) => {
|
||||||
if (!me) {
|
if (!me) {
|
||||||
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
|
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
|
||||||
|
@ -514,7 +457,7 @@ export default {
|
||||||
|
|
||||||
await models.$transaction([
|
await models.$transaction([
|
||||||
models.wallet.delete({ where: { userId: me.id, id: Number(id) } }),
|
models.wallet.delete({ where: { userId: me.id, id: Number(id) } }),
|
||||||
models.walletLog.create({ data: { userId: me.id, wallet: wallet.type, level: 'SUCCESS', message: 'wallet deleted' } })
|
models.walletLog.create({ data: { userId: me.id, wallet: wallet.type, level: 'SUCCESS', message: 'wallet detached' } })
|
||||||
])
|
])
|
||||||
|
|
||||||
return true
|
return true
|
||||||
|
@ -598,6 +541,8 @@ export default {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default injectResolvers(resolvers)
|
||||||
|
|
||||||
export const addWalletLog = async ({ wallet, level, message }, { me, models }) => {
|
export const addWalletLog = async ({ wallet, level, message }, { me, models }) => {
|
||||||
try {
|
try {
|
||||||
await models.walletLog.create({ data: { userId: me.id, wallet: wallet.type, level, message } })
|
await models.walletLog.create({ data: { userId: me.id, wallet: wallet.type, level, message } })
|
||||||
|
@ -620,13 +565,14 @@ async function upsertWallet (
|
||||||
await testConnect(data)
|
await testConnect(data)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
await addWalletLog({ wallet, level: 'ERROR', message: 'failed to attach wallet' }, { me, models })
|
const message = err.message || err.toString?.()
|
||||||
throw new GraphQLError('failed to connect to wallet', { extensions: { code: 'BAD_INPUT' } })
|
await addWalletLog({ wallet, level: 'ERROR', message: 'failed to attach: ' + message }, { me, models })
|
||||||
|
throw new GraphQLError(message, { extensions: { code: 'BAD_INPUT' } })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id, ...walletData } = data
|
const { id, ...walletData } = data
|
||||||
const { autoWithdrawThreshold, autoWithdrawMaxFeePercent, priority } = settings
|
const { autoWithdrawThreshold, autoWithdrawMaxFeePercent, enabled, priority } = settings
|
||||||
|
|
||||||
const txs = [
|
const txs = [
|
||||||
models.user.update({
|
models.user.update({
|
||||||
|
@ -638,24 +584,13 @@ async function upsertWallet (
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
|
|
||||||
if (priority) {
|
|
||||||
txs.push(
|
|
||||||
models.wallet.updateMany({
|
|
||||||
where: {
|
|
||||||
userId: me.id
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
priority: 0
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (id) {
|
if (id) {
|
||||||
txs.push(
|
txs.push(
|
||||||
models.wallet.update({
|
models.wallet.update({
|
||||||
where: { id: Number(id), userId: me.id },
|
where: { id: Number(id), userId: me.id },
|
||||||
data: {
|
data: {
|
||||||
priority: priority ? 1 : 0,
|
enabled,
|
||||||
|
priority,
|
||||||
[wallet.field]: {
|
[wallet.field]: {
|
||||||
update: {
|
update: {
|
||||||
where: { walletId: Number(id) },
|
where: { walletId: Number(id) },
|
||||||
|
@ -663,25 +598,43 @@ async function upsertWallet (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}),
|
})
|
||||||
models.walletLog.create({ data: { userId: me.id, wallet: wallet.type, level: 'SUCCESS', message: 'wallet updated' } })
|
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
txs.push(
|
txs.push(
|
||||||
models.wallet.create({
|
models.wallet.create({
|
||||||
data: {
|
data: {
|
||||||
priority: Number(priority),
|
enabled,
|
||||||
|
priority,
|
||||||
userId: me.id,
|
userId: me.id,
|
||||||
type: wallet.type,
|
type: wallet.type,
|
||||||
[wallet.field]: {
|
[wallet.field]: {
|
||||||
create: walletData
|
create: walletData
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}),
|
})
|
||||||
models.walletLog.create({ data: { userId: me.id, wallet: wallet.type, level: 'SUCCESS', message: 'wallet created' } })
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
txs.push(
|
||||||
|
models.walletLog.createMany({
|
||||||
|
data: {
|
||||||
|
userId: me.id,
|
||||||
|
wallet: wallet.type,
|
||||||
|
level: 'SUCCESS',
|
||||||
|
message: id ? 'wallet updated' : 'wallet attached'
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
models.walletLog.create({
|
||||||
|
data: {
|
||||||
|
userId: me.id,
|
||||||
|
wallet: wallet.type,
|
||||||
|
level: enabled ? 'SUCCESS' : 'INFO',
|
||||||
|
message: enabled ? 'wallet enabled' : 'wallet disabled'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
await models.$transaction(txs)
|
await models.$transaction(txs)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
@ -745,12 +698,28 @@ export async function createWithdrawal (parent, { invoice, maxFee }, { me, model
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendToLnAddr (parent, { addr, amount, maxFee, comment, ...payer },
|
export async function sendToLnAddr (parent, { addr, amount, maxFee, comment, ...payer },
|
||||||
{ me, models, lnd, headers, walletId }) {
|
{ me, models, lnd, headers }) {
|
||||||
if (!me) {
|
if (!me) {
|
||||||
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
|
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
|
||||||
}
|
}
|
||||||
assertApiKeyNotPermitted({ me })
|
assertApiKeyNotPermitted({ me })
|
||||||
|
|
||||||
|
const res = await fetchLnAddrInvoice({ addr, amount, maxFee, comment, ...payer },
|
||||||
|
{
|
||||||
|
me,
|
||||||
|
models,
|
||||||
|
lnd
|
||||||
|
})
|
||||||
|
|
||||||
|
// take pr and createWithdrawl
|
||||||
|
return await createWithdrawal(parent, { invoice: res.pr, maxFee }, { me, models, lnd, headers })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchLnAddrInvoice (
|
||||||
|
{ addr, amount, maxFee, comment, ...payer },
|
||||||
|
{
|
||||||
|
me, models, lnd, autoWithdraw = false
|
||||||
|
}) {
|
||||||
const options = await lnAddrOptions(addr)
|
const options = await lnAddrOptions(addr)
|
||||||
await ssValidate(lnAddrSchema, { addr, amount, maxFee, comment, ...payer }, options)
|
await ssValidate(lnAddrSchema, { addr, amount, maxFee, comment, ...payer }, options)
|
||||||
|
|
||||||
|
@ -788,10 +757,10 @@ export async function sendToLnAddr (parent, { addr, amount, maxFee, comment, ...
|
||||||
try {
|
try {
|
||||||
const decoded = await decodePaymentRequest({ lnd, request: res.pr })
|
const decoded = await decodePaymentRequest({ lnd, request: res.pr })
|
||||||
const ourPubkey = (await getIdentity({ lnd })).public_key
|
const ourPubkey = (await getIdentity({ lnd })).public_key
|
||||||
if (walletId && decoded.destination === ourPubkey && process.env.NODE_ENV === 'production') {
|
if (autoWithdraw && decoded.destination === ourPubkey && process.env.NODE_ENV === 'production') {
|
||||||
// unset lnaddr so we don't trigger another withdrawal with same destination
|
// unset lnaddr so we don't trigger another withdrawal with same destination
|
||||||
await models.wallet.deleteMany({
|
await models.wallet.deleteMany({
|
||||||
where: { userId: me.id, type: Wallet.LnAddr.type }
|
where: { userId: me.id, type: 'LIGHTNING_ADDRESS' }
|
||||||
})
|
})
|
||||||
throw new Error('automated withdrawals to other stackers are not allowed')
|
throw new Error('automated withdrawals to other stackers are not allowed')
|
||||||
}
|
}
|
||||||
|
@ -803,6 +772,5 @@ export async function sendToLnAddr (parent, { addr, amount, maxFee, comment, ...
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
|
|
||||||
// take pr and createWithdrawl
|
return res
|
||||||
return await createWithdrawal(parent, { invoice: res.pr, maxFee }, { me, models, lnd, headers, walletId })
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,32 @@
|
||||||
import { gql } from 'graphql-tag'
|
import { gql } from 'graphql-tag'
|
||||||
|
import { generateResolverName } from '@/lib/wallet'
|
||||||
|
|
||||||
export default gql`
|
import walletDefs from 'wallets/server'
|
||||||
|
|
||||||
|
function injectTypeDefs (typeDefs) {
|
||||||
|
console.group('injected GraphQL type defs:')
|
||||||
|
const injected = walletDefs.map(
|
||||||
|
(w) => {
|
||||||
|
let args = 'id: ID, '
|
||||||
|
args += w.fields.map(f => {
|
||||||
|
let arg = `${f.name}: String`
|
||||||
|
if (!f.optional) {
|
||||||
|
arg += '!'
|
||||||
|
}
|
||||||
|
return arg
|
||||||
|
}).join(', ')
|
||||||
|
args += ', settings: AutowithdrawSettings!'
|
||||||
|
const resolverName = generateResolverName(w.walletField)
|
||||||
|
const typeDef = `${resolverName}(${args}): Boolean`
|
||||||
|
console.log(typeDef)
|
||||||
|
return typeDef
|
||||||
|
})
|
||||||
|
console.groupEnd()
|
||||||
|
|
||||||
|
return `${typeDefs}\n\nextend type Mutation {\n${injected.join('\n')}\n}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeDefs = `
|
||||||
extend type Query {
|
extend type Query {
|
||||||
invoice(id: ID!): Invoice!
|
invoice(id: ID!): Invoice!
|
||||||
withdrawl(id: ID!): Withdrawl!
|
withdrawl(id: ID!): Withdrawl!
|
||||||
|
@ -19,9 +45,6 @@ export default gql`
|
||||||
sendToLnAddr(addr: String!, amount: Int!, maxFee: Int!, comment: String, identifier: Boolean, name: String, email: String): Withdrawl!
|
sendToLnAddr(addr: String!, amount: Int!, maxFee: Int!, comment: String, identifier: Boolean, name: String, email: String): Withdrawl!
|
||||||
cancelInvoice(hash: String!, hmac: String!): Invoice!
|
cancelInvoice(hash: String!, hmac: String!): Invoice!
|
||||||
dropBolt11(id: ID): Withdrawl
|
dropBolt11(id: ID): Withdrawl
|
||||||
upsertWalletLND(id: ID, socket: String!, macaroon: String!, cert: String, settings: AutowithdrawSettings!): Boolean
|
|
||||||
upsertWalletCLN(id: ID, socket: String!, rune: String!, cert: String, settings: AutowithdrawSettings!): Boolean
|
|
||||||
upsertWalletLNAddr(id: ID, address: String!, settings: AutowithdrawSettings!): Boolean
|
|
||||||
removeWallet(id: ID!): Boolean
|
removeWallet(id: ID!): Boolean
|
||||||
deleteWalletLogs(wallet: String): Boolean
|
deleteWalletLogs(wallet: String): Boolean
|
||||||
}
|
}
|
||||||
|
@ -30,7 +53,8 @@ export default gql`
|
||||||
id: ID!
|
id: ID!
|
||||||
createdAt: Date!
|
createdAt: Date!
|
||||||
type: String!
|
type: String!
|
||||||
priority: Boolean!
|
enabled: Boolean!
|
||||||
|
priority: Int!
|
||||||
wallet: WalletDetails!
|
wallet: WalletDetails!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -55,7 +79,8 @@ export default gql`
|
||||||
input AutowithdrawSettings {
|
input AutowithdrawSettings {
|
||||||
autoWithdrawThreshold: Int!
|
autoWithdrawThreshold: Int!
|
||||||
autoWithdrawMaxFeePercent: Float!
|
autoWithdrawMaxFeePercent: Float!
|
||||||
priority: Boolean!
|
priority: Int
|
||||||
|
enabled: Boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type Invoice {
|
type Invoice {
|
||||||
|
@ -123,3 +148,5 @@ export default gql`
|
||||||
message: String!
|
message: String!
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
export default gql`${injectTypeDefs(typeDefs)}`
|
||||||
|
|
|
@ -8,15 +8,14 @@ function autoWithdrawThreshold ({ me }) {
|
||||||
return isNumber(me?.privates?.autoWithdrawThreshold) ? me?.privates?.autoWithdrawThreshold : 10000
|
return isNumber(me?.privates?.autoWithdrawThreshold) ? me?.privates?.autoWithdrawThreshold : 10000
|
||||||
}
|
}
|
||||||
|
|
||||||
export function autowithdrawInitial ({ me, priority = false }) {
|
export function autowithdrawInitial ({ me }) {
|
||||||
return {
|
return {
|
||||||
priority,
|
|
||||||
autoWithdrawThreshold: autoWithdrawThreshold({ me }),
|
autoWithdrawThreshold: autoWithdrawThreshold({ me }),
|
||||||
autoWithdrawMaxFeePercent: isNumber(me?.privates?.autoWithdrawMaxFeePercent) ? me?.privates?.autoWithdrawMaxFeePercent : 1
|
autoWithdrawMaxFeePercent: isNumber(me?.privates?.autoWithdrawMaxFeePercent) ? me?.privates?.autoWithdrawMaxFeePercent : 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AutowithdrawSettings ({ priority }) {
|
export function AutowithdrawSettings ({ wallet }) {
|
||||||
const me = useMe()
|
const me = useMe()
|
||||||
const threshold = autoWithdrawThreshold({ me })
|
const threshold = autoWithdrawThreshold({ me })
|
||||||
|
|
||||||
|
@ -29,9 +28,10 @@ export function AutowithdrawSettings ({ priority }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label='make default autowithdraw method'
|
disabled={!wallet.isConfigured}
|
||||||
id='priority'
|
label='enabled'
|
||||||
name='priority'
|
id='enabled'
|
||||||
|
name='enabled'
|
||||||
/>
|
/>
|
||||||
<div className='my-4 border border-3 rounded'>
|
<div className='my-4 border border-3 rounded'>
|
||||||
<div className='p-3'>
|
<div className='p-3'>
|
||||||
|
@ -46,12 +46,14 @@ export function AutowithdrawSettings ({ priority }) {
|
||||||
}}
|
}}
|
||||||
hint={isNumber(sendThreshold) ? `will attempt auto-withdraw when your balance exceeds ${sendThreshold * 11} sats` : undefined}
|
hint={isNumber(sendThreshold) ? `will attempt auto-withdraw when your balance exceeds ${sendThreshold * 11} sats` : undefined}
|
||||||
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
|
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label='max fee'
|
label='max fee'
|
||||||
name='autoWithdrawMaxFeePercent'
|
name='autoWithdrawMaxFeePercent'
|
||||||
hint='max fee as percent of withdrawal amount'
|
hint='max fee as percent of withdrawal amount'
|
||||||
append={<InputGroup.Text>%</InputGroup.Text>}
|
append={<InputGroup.Text>%</InputGroup.Text>}
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -8,17 +8,13 @@ import Bolt11Info from './bolt11-info'
|
||||||
import { useQuery } from '@apollo/client'
|
import { useQuery } from '@apollo/client'
|
||||||
import { INVOICE } from '@/fragments/wallet'
|
import { INVOICE } from '@/fragments/wallet'
|
||||||
import { FAST_POLL_INTERVAL, SSR } from '@/lib/constants'
|
import { FAST_POLL_INTERVAL, SSR } from '@/lib/constants'
|
||||||
import { WebLnNotEnabledError } from './payment'
|
import { NoAttachedWalletError } from './payment'
|
||||||
import ItemJob from './item-job'
|
import ItemJob from './item-job'
|
||||||
import Item from './item'
|
import Item from './item'
|
||||||
import { CommentFlat } from './comment'
|
import { CommentFlat } from './comment'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
|
|
||||||
export default function Invoice ({
|
export default function Invoice ({ id, query = INVOICE, modal, onPayment, onCanceled, info, successVerb, useWallet = true, walletError, poll, waitFor, ...props }) {
|
||||||
id, query = INVOICE, modal, onPayment, onCanceled,
|
|
||||||
info, successVerb, webLn = true, webLnError,
|
|
||||||
poll, waitFor, ...props
|
|
||||||
}) {
|
|
||||||
const [expired, setExpired] = useState(false)
|
const [expired, setExpired] = useState(false)
|
||||||
const { data, error } = useQuery(query, SSR
|
const { data, error } = useQuery(query, SSR
|
||||||
? {}
|
? {}
|
||||||
|
@ -58,15 +54,15 @@ export default function Invoice ({
|
||||||
if (invoice.cancelled) {
|
if (invoice.cancelled) {
|
||||||
variant = 'failed'
|
variant = 'failed'
|
||||||
status = 'cancelled'
|
status = 'cancelled'
|
||||||
webLn = false
|
useWallet = false
|
||||||
} else if (invoice.confirmedAt || (invoice.isHeld && invoice.satsReceived && !expired)) {
|
} else if (invoice.confirmedAt || (invoice.isHeld && invoice.satsReceived && !expired)) {
|
||||||
variant = 'confirmed'
|
variant = 'confirmed'
|
||||||
status = `${numWithUnits(invoice.satsReceived, { abbreviate: false })} ${successVerb || 'deposited'}`
|
status = `${numWithUnits(invoice.satsReceived, { abbreviate: false })} ${successVerb || 'deposited'}`
|
||||||
webLn = false
|
useWallet = false
|
||||||
} else if (expired) {
|
} else if (expired) {
|
||||||
variant = 'failed'
|
variant = 'failed'
|
||||||
status = 'expired'
|
status = 'expired'
|
||||||
webLn = false
|
useWallet = false
|
||||||
} else if (invoice.expiresAt) {
|
} else if (invoice.expiresAt) {
|
||||||
variant = 'pending'
|
variant = 'pending'
|
||||||
status = (
|
status = (
|
||||||
|
@ -82,13 +78,13 @@ export default function Invoice ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{webLnError && !(webLnError instanceof WebLnNotEnabledError) &&
|
{walletError && !(walletError instanceof NoAttachedWalletError) &&
|
||||||
<div className='text-center fw-bold text-info mb-3' style={{ lineHeight: 1.25 }}>
|
<div className='text-center fw-bold text-info mb-3' style={{ lineHeight: 1.25 }}>
|
||||||
Paying from attached wallet failed:
|
Paying from attached wallet failed:
|
||||||
<code> {webLnError.message}</code>
|
<code> {walletError.message}</code>
|
||||||
</div>}
|
</div>}
|
||||||
<Qr
|
<Qr
|
||||||
webLn={webLn} value={invoice.bolt11}
|
useWallet={useWallet} value={invoice.bolt11}
|
||||||
description={numWithUnits(invoice.satsRequested, { abbreviate: false })}
|
description={numWithUnits(invoice.satsRequested, { abbreviate: false })}
|
||||||
statusVariant={variant} status={status}
|
statusVariant={variant} status={status}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -1,9 +1,6 @@
|
||||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||||
import { useMe } from './me'
|
import { useMe } from './me'
|
||||||
import fancyNames from '@/lib/fancy-names.json'
|
import fancyNames from '@/lib/fancy-names.json'
|
||||||
import { gql, useMutation, useQuery } from '@apollo/client'
|
|
||||||
import { WALLET_LOGS } from '@/fragments/wallet'
|
|
||||||
import { getWalletBy } from '@/lib/constants'
|
|
||||||
|
|
||||||
const generateFancyName = () => {
|
const generateFancyName = () => {
|
||||||
// 100 adjectives * 100 nouns * 10000 = 100M possible names
|
// 100 adjectives * 100 nouns * 10000 = 100M possible names
|
||||||
|
@ -44,9 +41,7 @@ export const LoggerContext = createContext()
|
||||||
export const LoggerProvider = ({ children }) => {
|
export const LoggerProvider = ({ children }) => {
|
||||||
return (
|
return (
|
||||||
<ServiceWorkerLoggerProvider>
|
<ServiceWorkerLoggerProvider>
|
||||||
<WalletLoggerProvider>
|
{children}
|
||||||
{children}
|
|
||||||
</WalletLoggerProvider>
|
|
||||||
</ServiceWorkerLoggerProvider>
|
</ServiceWorkerLoggerProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -122,189 +117,3 @@ function ServiceWorkerLoggerProvider ({ children }) {
|
||||||
export function useServiceWorkerLogger () {
|
export function useServiceWorkerLogger () {
|
||||||
return useContext(ServiceWorkerLoggerContext)
|
return useContext(ServiceWorkerLoggerContext)
|
||||||
}
|
}
|
||||||
|
|
||||||
const WalletLoggerContext = createContext()
|
|
||||||
const WalletLogsContext = createContext()
|
|
||||||
|
|
||||||
const initIndexedDB = async (dbName, storeName) => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
if (!window.indexedDB) {
|
|
||||||
return reject(new Error('IndexedDB not supported'))
|
|
||||||
}
|
|
||||||
|
|
||||||
// https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API/Using_IndexedDB
|
|
||||||
const request = window.indexedDB.open(dbName, 1)
|
|
||||||
|
|
||||||
let db
|
|
||||||
request.onupgradeneeded = () => {
|
|
||||||
// this only runs if version was changed during open
|
|
||||||
db = request.result
|
|
||||||
if (!db.objectStoreNames.contains(storeName)) {
|
|
||||||
const objectStore = db.createObjectStore(storeName, { autoIncrement: true })
|
|
||||||
objectStore.createIndex('ts', 'ts')
|
|
||||||
objectStore.createIndex('wallet_ts', ['wallet', 'ts'])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
request.onsuccess = () => {
|
|
||||||
// this gets called after onupgradeneeded finished
|
|
||||||
db = request.result
|
|
||||||
resolve(db)
|
|
||||||
}
|
|
||||||
|
|
||||||
request.onerror = () => {
|
|
||||||
reject(new Error('failed to open IndexedDB'))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const WalletLoggerProvider = ({ children }) => {
|
|
||||||
const me = useMe()
|
|
||||||
const [logs, setLogs] = useState([])
|
|
||||||
let dbName = 'app:storage'
|
|
||||||
if (me) {
|
|
||||||
dbName = `${dbName}:${me.id}`
|
|
||||||
}
|
|
||||||
const idbStoreName = 'wallet_logs'
|
|
||||||
const idb = useRef()
|
|
||||||
const logQueue = useRef([])
|
|
||||||
|
|
||||||
useQuery(WALLET_LOGS, {
|
|
||||||
fetchPolicy: 'network-only',
|
|
||||||
// required to trigger onCompleted on refetches
|
|
||||||
notifyOnNetworkStatusChange: true,
|
|
||||||
onCompleted: ({ walletLogs }) => {
|
|
||||||
setLogs((prevLogs) => {
|
|
||||||
const existingIds = prevLogs.map(({ id }) => id)
|
|
||||||
const logs = walletLogs
|
|
||||||
.filter(({ id }) => !existingIds.includes(id))
|
|
||||||
.map(({ createdAt, wallet: walletType, ...log }) => {
|
|
||||||
return {
|
|
||||||
ts: +new Date(createdAt),
|
|
||||||
wallet: getWalletBy('type', walletType).logTag,
|
|
||||||
...log
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return [...prevLogs, ...logs].sort((a, b) => a.ts - b.ts)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const [deleteServerWalletLogs] = useMutation(
|
|
||||||
gql`
|
|
||||||
mutation deleteWalletLogs($wallet: String) {
|
|
||||||
deleteWalletLogs(wallet: $wallet)
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
{
|
|
||||||
onCompleted: (_, { variables: { wallet: walletType } }) => {
|
|
||||||
setLogs((logs) => {
|
|
||||||
return logs.filter(l => walletType ? l.wallet !== getWalletBy('type', walletType).logTag : false)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const saveLog = useCallback((log) => {
|
|
||||||
if (!idb.current) {
|
|
||||||
// IDB may not be ready yet
|
|
||||||
return logQueue.current.push(log)
|
|
||||||
}
|
|
||||||
const tx = idb.current.transaction(idbStoreName, 'readwrite')
|
|
||||||
const request = tx.objectStore(idbStoreName).add(log)
|
|
||||||
request.onerror = () => console.error('failed to save log:', log)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
initIndexedDB(dbName, idbStoreName)
|
|
||||||
.then(db => {
|
|
||||||
idb.current = db
|
|
||||||
|
|
||||||
// load all logs from IDB
|
|
||||||
const tx = idb.current.transaction(idbStoreName, 'readonly')
|
|
||||||
const store = tx.objectStore(idbStoreName)
|
|
||||||
const index = store.index('ts')
|
|
||||||
const request = index.getAll()
|
|
||||||
request.onsuccess = () => {
|
|
||||||
const logs = request.result
|
|
||||||
setLogs((prevLogs) => {
|
|
||||||
// sort oldest first to keep same order as logs are appended
|
|
||||||
return [...prevLogs, ...logs].sort((a, b) => a.ts - b.ts)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// flush queued logs to IDB
|
|
||||||
logQueue.current.forEach(q => {
|
|
||||||
const isLog = !!q.wallet
|
|
||||||
if (isLog) saveLog(q)
|
|
||||||
})
|
|
||||||
|
|
||||||
logQueue.current = []
|
|
||||||
})
|
|
||||||
.catch(console.error)
|
|
||||||
return () => idb.current?.close()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const appendLog = useCallback((wallet, level, message) => {
|
|
||||||
const log = { wallet: wallet.logTag, level, message, ts: +new Date() }
|
|
||||||
saveLog(log)
|
|
||||||
setLogs((prevLogs) => [...prevLogs, log])
|
|
||||||
}, [saveLog])
|
|
||||||
|
|
||||||
const deleteLogs = useCallback(async (wallet) => {
|
|
||||||
if (!wallet || wallet.server) {
|
|
||||||
await deleteServerWalletLogs({ variables: { wallet: wallet?.type } })
|
|
||||||
}
|
|
||||||
if (!wallet || !wallet.server) {
|
|
||||||
const tx = idb.current.transaction(idbStoreName, 'readwrite')
|
|
||||||
const objectStore = tx.objectStore(idbStoreName)
|
|
||||||
const idx = objectStore.index('wallet_ts')
|
|
||||||
const request = wallet ? idx.openCursor(window.IDBKeyRange.bound([wallet.logTag, -Infinity], [wallet.logTag, Infinity])) : idx.openCursor()
|
|
||||||
request.onsuccess = function (event) {
|
|
||||||
const cursor = event.target.result
|
|
||||||
if (cursor) {
|
|
||||||
cursor.delete()
|
|
||||||
cursor.continue()
|
|
||||||
} else {
|
|
||||||
// finished
|
|
||||||
setLogs((logs) => logs.filter(l => wallet ? l.wallet !== wallet.logTag : false))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [setLogs])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<WalletLogsContext.Provider value={logs}>
|
|
||||||
<WalletLoggerContext.Provider value={{ appendLog, deleteLogs }}>
|
|
||||||
{children}
|
|
||||||
</WalletLoggerContext.Provider>
|
|
||||||
</WalletLogsContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useWalletLogger (wallet) {
|
|
||||||
const { appendLog, deleteLogs: innerDeleteLogs } = useContext(WalletLoggerContext)
|
|
||||||
|
|
||||||
const log = useCallback(level => message => {
|
|
||||||
// TODO:
|
|
||||||
// also send this to us if diagnostics was enabled,
|
|
||||||
// very similar to how the service worker logger works.
|
|
||||||
appendLog(wallet, level, message)
|
|
||||||
console[level !== 'error' ? 'info' : 'error'](`[${wallet.logTag}]`, message)
|
|
||||||
}, [appendLog, wallet])
|
|
||||||
|
|
||||||
const logger = useMemo(() => ({
|
|
||||||
ok: (...message) => log('ok')(message.join(' ')),
|
|
||||||
info: (...message) => log('info')(message.join(' ')),
|
|
||||||
error: (...message) => log('error')(message.join(' '))
|
|
||||||
}), [log, wallet])
|
|
||||||
|
|
||||||
const deleteLogs = useCallback((w) => innerDeleteLogs(w || wallet), [innerDeleteLogs, wallet])
|
|
||||||
|
|
||||||
return { logger, deleteLogs }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useWalletLogs (wallet) {
|
|
||||||
const logs = useContext(WalletLogsContext)
|
|
||||||
return logs.filter(l => !wallet || l.wallet === wallet.logTag)
|
|
||||||
}
|
|
||||||
|
|
|
@ -6,7 +6,7 @@ import BackArrow from '../../svgs/arrow-left-line.svg'
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import Price from '../price'
|
import Price from '../price'
|
||||||
import SubSelect from '../sub-select'
|
import SubSelect from '../sub-select'
|
||||||
import { USER_ID, BALANCE_LIMIT_MSATS, Wallet } from '../../lib/constants'
|
import { USER_ID, BALANCE_LIMIT_MSATS } from '../../lib/constants'
|
||||||
import Head from 'next/head'
|
import Head from 'next/head'
|
||||||
import NoteIcon from '../../svgs/notification-4-fill.svg'
|
import NoteIcon from '../../svgs/notification-4-fill.svg'
|
||||||
import { useMe } from '../me'
|
import { useMe } from '../me'
|
||||||
|
@ -22,8 +22,7 @@ import SearchIcon from '../../svgs/search-line.svg'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
import SnIcon from '@/svgs/sn.svg'
|
import SnIcon from '@/svgs/sn.svg'
|
||||||
import { useHasNewNotes } from '../use-has-new-notes'
|
import { useHasNewNotes } from '../use-has-new-notes'
|
||||||
import { useWalletLogger } from '../logger'
|
import { useWallets } from 'wallets'
|
||||||
import { useWebLNConfigurator } from '../webln'
|
|
||||||
|
|
||||||
export function Brand ({ className }) {
|
export function Brand ({ className }) {
|
||||||
return (
|
return (
|
||||||
|
@ -257,8 +256,7 @@ export default function LoginButton ({ className }) {
|
||||||
|
|
||||||
export function LogoutDropdownItem () {
|
export function LogoutDropdownItem () {
|
||||||
const { registration: swRegistration, togglePushSubscription } = useServiceWorker()
|
const { registration: swRegistration, togglePushSubscription } = useServiceWorker()
|
||||||
const webLN = useWebLNConfigurator()
|
const wallets = useWallets()
|
||||||
const { deleteLogs } = useWalletLogger()
|
|
||||||
return (
|
return (
|
||||||
<Dropdown.Item
|
<Dropdown.Item
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
|
@ -267,12 +265,9 @@ export function LogoutDropdownItem () {
|
||||||
if (pushSubscription) {
|
if (pushSubscription) {
|
||||||
await togglePushSubscription().catch(console.error)
|
await togglePushSubscription().catch(console.error)
|
||||||
}
|
}
|
||||||
// detach wallets
|
|
||||||
await webLN.clearConfig().catch(console.error)
|
await wallets.resetClient().catch(console.error)
|
||||||
// delete client wallet logs to prevent leak of private data if a shared device was used
|
|
||||||
await deleteLogs(Wallet.NWC).catch(console.error)
|
|
||||||
await deleteLogs(Wallet.LNbits).catch(console.error)
|
|
||||||
await deleteLogs(Wallet.LNC).catch(console.error)
|
|
||||||
await signOut({ callbackUrl: '/' })
|
await signOut({ callbackUrl: '/' })
|
||||||
}}
|
}}
|
||||||
>logout
|
>logout
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { useCallback, useMemo } from 'react'
|
import { useCallback, useMemo } from 'react'
|
||||||
import { useMe } from './me'
|
import { useMe } from './me'
|
||||||
import { gql, useApolloClient, useMutation } from '@apollo/client'
|
import { gql, useApolloClient, useMutation } from '@apollo/client'
|
||||||
import { useWebLN } from './webln'
|
import { useWallet } from 'wallets'
|
||||||
import { FAST_POLL_INTERVAL, JIT_INVOICE_TIMEOUT_MS } from '@/lib/constants'
|
import { FAST_POLL_INTERVAL, JIT_INVOICE_TIMEOUT_MS } from '@/lib/constants'
|
||||||
import { INVOICE } from '@/fragments/wallet'
|
import { INVOICE } from '@/fragments/wallet'
|
||||||
import Invoice from '@/components/invoice'
|
import Invoice from '@/components/invoice'
|
||||||
|
@ -17,10 +17,10 @@ export class InvoiceCanceledError extends Error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class WebLnNotEnabledError extends Error {
|
export class NoAttachedWalletError extends Error {
|
||||||
constructor () {
|
constructor () {
|
||||||
super('no enabled WebLN provider found')
|
super('no attached wallet found')
|
||||||
this.name = 'WebLnNotEnabledError'
|
this.name = 'NoAttachedWalletError'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -126,19 +126,19 @@ export const useInvoice = () => {
|
||||||
return { create, waitUntilPaid: waitController.wait, stopWaiting: waitController.stop, cancel }
|
return { create, waitUntilPaid: waitController.wait, stopWaiting: waitController.stop, cancel }
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useWebLnPayment = () => {
|
export const useWalletPayment = () => {
|
||||||
const invoice = useInvoice()
|
const invoice = useInvoice()
|
||||||
const provider = useWebLN()
|
const wallet = useWallet()
|
||||||
|
|
||||||
const waitForWebLnPayment = useCallback(async ({ id, bolt11 }, waitFor) => {
|
const waitForWalletPayment = useCallback(async ({ id, bolt11 }, waitFor) => {
|
||||||
if (!provider) {
|
if (!wallet) {
|
||||||
throw new WebLnNotEnabledError()
|
throw new NoAttachedWalletError()
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return await new Promise((resolve, reject) => {
|
return await new Promise((resolve, reject) => {
|
||||||
// can't use await here since we might pay JIT invoices and sendPaymentAsync is not supported yet.
|
// can't use await here since we might pay JIT invoices and sendPaymentAsync is not supported yet.
|
||||||
// see https://www.webln.guide/building-lightning-apps/webln-reference/webln.sendpaymentasync
|
// see https://www.webln.guide/building-lightning-apps/webln-reference/webln.sendpaymentasync
|
||||||
provider.sendPayment(bolt11)
|
wallet.sendPayment(bolt11)
|
||||||
// JIT invoice payments will never resolve here
|
// JIT invoice payments will never resolve here
|
||||||
// since they only get resolved after settlement which can't happen here
|
// since they only get resolved after settlement which can't happen here
|
||||||
.then(resolve)
|
.then(resolve)
|
||||||
|
@ -148,21 +148,21 @@ export const useWebLnPayment = () => {
|
||||||
.catch(reject)
|
.catch(reject)
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('WebLN payment failed:', err)
|
console.error('payment failed:', err)
|
||||||
throw err
|
throw err
|
||||||
} finally {
|
} finally {
|
||||||
invoice.stopWaiting()
|
invoice.stopWaiting()
|
||||||
}
|
}
|
||||||
}, [provider, invoice])
|
}, [wallet, invoice])
|
||||||
|
|
||||||
return waitForWebLnPayment
|
return waitForWalletPayment
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useQrPayment = () => {
|
export const useQrPayment = () => {
|
||||||
const invoice = useInvoice()
|
const invoice = useInvoice()
|
||||||
const showModal = useShowModal()
|
const showModal = useShowModal()
|
||||||
|
|
||||||
const waitForQrPayment = useCallback(async (inv, webLnError,
|
const waitForQrPayment = useCallback(async (inv, walletError,
|
||||||
{
|
{
|
||||||
keepOpen = true,
|
keepOpen = true,
|
||||||
cancelOnClose = true,
|
cancelOnClose = true,
|
||||||
|
@ -186,8 +186,8 @@ export const useQrPayment = () => {
|
||||||
description
|
description
|
||||||
status='loading'
|
status='loading'
|
||||||
successVerb='received'
|
successVerb='received'
|
||||||
webLn={false}
|
useWallet={false}
|
||||||
webLnError={webLnError}
|
walletError={walletError}
|
||||||
waitFor={waitFor}
|
waitFor={waitFor}
|
||||||
onCanceled={inv => { onClose(); reject(new InvoiceCanceledError(inv?.hash, inv?.actionError)) }}
|
onCanceled={inv => { onClose(); reject(new InvoiceCanceledError(inv?.hash, inv?.actionError)) }}
|
||||||
onPayment={() => { paid = true; onClose(); resolve() }}
|
onPayment={() => { paid = true; onClose(); resolve() }}
|
||||||
|
@ -204,22 +204,22 @@ export const usePayment = () => {
|
||||||
const me = useMe()
|
const me = useMe()
|
||||||
const feeButton = useFeeButton()
|
const feeButton = useFeeButton()
|
||||||
const invoice = useInvoice()
|
const invoice = useInvoice()
|
||||||
const waitForWebLnPayment = useWebLnPayment()
|
const waitForWalletPayment = useWalletPayment()
|
||||||
const waitForQrPayment = useQrPayment()
|
const waitForQrPayment = useQrPayment()
|
||||||
|
|
||||||
const waitForPayment = useCallback(async (invoice) => {
|
const waitForPayment = useCallback(async (invoice) => {
|
||||||
let webLnError
|
let walletError
|
||||||
try {
|
try {
|
||||||
return await waitForWebLnPayment(invoice)
|
return await waitForWalletPayment(invoice)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof InvoiceCanceledError || err instanceof InvoiceExpiredError) {
|
if (err instanceof InvoiceCanceledError || err instanceof InvoiceExpiredError) {
|
||||||
// bail since qr code payment will also fail
|
// bail since qr code payment will also fail
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
webLnError = err
|
walletError = err
|
||||||
}
|
}
|
||||||
return await waitForQrPayment(invoice, webLnError)
|
return await waitForQrPayment(invoice, walletError)
|
||||||
}, [waitForWebLnPayment, waitForQrPayment])
|
}, [waitForWalletPayment, waitForQrPayment])
|
||||||
|
|
||||||
const request = useCallback(async (amount) => {
|
const request = useCallback(async (amount) => {
|
||||||
amount ??= feeButton?.total
|
amount ??= feeButton?.total
|
||||||
|
|
|
@ -2,25 +2,25 @@ import QRCode from 'qrcode.react'
|
||||||
import { CopyInput, InputSkeleton } from './form'
|
import { CopyInput, InputSkeleton } from './form'
|
||||||
import InvoiceStatus from './invoice-status'
|
import InvoiceStatus from './invoice-status'
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useWebLN } from './webln'
|
import { useWallet } from 'wallets'
|
||||||
import Bolt11Info from './bolt11-info'
|
import Bolt11Info from './bolt11-info'
|
||||||
|
|
||||||
export default function Qr ({ asIs, value, webLn, statusVariant, description, status }) {
|
export default function Qr ({ asIs, value, useWallet: automated, statusVariant, description, status }) {
|
||||||
const qrValue = asIs ? value : 'lightning:' + value.toUpperCase()
|
const qrValue = asIs ? value : 'lightning:' + value.toUpperCase()
|
||||||
const provider = useWebLN()
|
const wallet = useWallet()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function effect () {
|
async function effect () {
|
||||||
if (webLn && provider) {
|
if (automated && wallet) {
|
||||||
try {
|
try {
|
||||||
await provider.sendPayment(value)
|
await wallet.sendPayment(value)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e?.message)
|
console.log(e?.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
effect()
|
effect()
|
||||||
}, [provider])
|
}, [wallet])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { SSR } from '@/lib/constants'
|
||||||
|
import { useCallback, useState } from 'react'
|
||||||
|
|
||||||
|
export default function useLocalState (storageKey, initialValue = '') {
|
||||||
|
const [value, innerSetValue] = useState(
|
||||||
|
initialValue ||
|
||||||
|
(SSR ? null : JSON.parse(window.localStorage.getItem(storageKey)))
|
||||||
|
)
|
||||||
|
|
||||||
|
const setValue = useCallback((newValue) => {
|
||||||
|
window.localStorage.setItem(storageKey, JSON.stringify(newValue))
|
||||||
|
innerSetValue(newValue)
|
||||||
|
}, [storageKey])
|
||||||
|
|
||||||
|
const clearValue = useCallback(() => {
|
||||||
|
window.localStorage.removeItem(storageKey)
|
||||||
|
innerSetValue(null)
|
||||||
|
}, [storageKey])
|
||||||
|
|
||||||
|
return [value, setValue, clearValue]
|
||||||
|
}
|
|
@ -1,6 +1,6 @@
|
||||||
import { useApolloClient, useLazyQuery, useMutation } from '@apollo/client'
|
import { useApolloClient, useLazyQuery, useMutation } from '@apollo/client'
|
||||||
import { useCallback, useState } from 'react'
|
import { useCallback, useState } from 'react'
|
||||||
import { InvoiceCanceledError, InvoiceExpiredError, useQrPayment, useWebLnPayment } from './payment'
|
import { InvoiceCanceledError, InvoiceExpiredError, useQrPayment, useWalletPayment } from './payment'
|
||||||
import { GET_PAID_ACTION } from '@/fragments/paidAction'
|
import { GET_PAID_ACTION } from '@/fragments/paidAction'
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
@ -22,27 +22,27 @@ export function usePaidMutation (mutation,
|
||||||
const [getPaidAction] = useLazyQuery(GET_PAID_ACTION, {
|
const [getPaidAction] = useLazyQuery(GET_PAID_ACTION, {
|
||||||
fetchPolicy: 'network-only'
|
fetchPolicy: 'network-only'
|
||||||
})
|
})
|
||||||
const waitForWebLnPayment = useWebLnPayment()
|
const waitForWalletPayment = useWalletPayment()
|
||||||
const waitForQrPayment = useQrPayment()
|
const waitForQrPayment = useQrPayment()
|
||||||
const client = useApolloClient()
|
const client = useApolloClient()
|
||||||
// innerResult is used to store/control the result of the mutation when innerMutate runs
|
// innerResult is used to store/control the result of the mutation when innerMutate runs
|
||||||
const [innerResult, setInnerResult] = useState(result)
|
const [innerResult, setInnerResult] = useState(result)
|
||||||
|
|
||||||
const waitForPayment = useCallback(async (invoice, { persistOnNavigate = false, waitFor }) => {
|
const waitForPayment = useCallback(async (invoice, { persistOnNavigate = false, waitFor }) => {
|
||||||
let webLnError
|
let walletError
|
||||||
const start = Date.now()
|
const start = Date.now()
|
||||||
try {
|
try {
|
||||||
return await waitForWebLnPayment(invoice, waitFor)
|
return await waitForWalletPayment(invoice, waitFor)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (Date.now() - start > 1000 || err instanceof InvoiceCanceledError || err instanceof InvoiceExpiredError) {
|
if (Date.now() - start > 1000 || err instanceof InvoiceCanceledError || err instanceof InvoiceExpiredError) {
|
||||||
// bail since qr code payment will also fail
|
// bail since qr code payment will also fail
|
||||||
// also bail if the payment took more than 1 second
|
// also bail if the payment took more than 1 second
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
webLnError = err
|
walletError = err
|
||||||
}
|
}
|
||||||
return await waitForQrPayment(invoice, webLnError, { persistOnNavigate, waitFor })
|
return await waitForQrPayment(invoice, walletError, { persistOnNavigate, waitFor })
|
||||||
}, [waitForWebLnPayment, waitForQrPayment])
|
}, [waitForWalletPayment, waitForQrPayment])
|
||||||
|
|
||||||
const innerMutate = useCallback(async ({
|
const innerMutate = useCallback(async ({
|
||||||
onCompleted: innerOnCompleted, ...innerOptions
|
onCompleted: innerOnCompleted, ...innerOptions
|
||||||
|
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { Button } from 'react-bootstrap'
|
||||||
|
import CancelButton from './cancel-button'
|
||||||
|
import { SubmitButton } from './form'
|
||||||
|
|
||||||
|
export default function WalletButtonBar ({
|
||||||
|
wallet, disable,
|
||||||
|
className, children, onDelete, onCancel, hasCancel = true,
|
||||||
|
createText = 'attach', deleteText = 'detach', editText = 'save'
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={`mt-3 ${className}`}>
|
||||||
|
<div className='d-flex justify-content-between'>
|
||||||
|
{wallet.isConfigured &&
|
||||||
|
<Button onClick={onDelete} variant='grey-medium'>{deleteText}</Button>}
|
||||||
|
{children}
|
||||||
|
<div className='d-flex align-items-center ms-auto'>
|
||||||
|
{hasCancel && <CancelButton onClick={onCancel} />}
|
||||||
|
<SubmitButton variant='primary' disabled={disable}>{wallet.isConfigured ? editText : createText}</SubmitButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,18 +1,15 @@
|
||||||
import { Badge, Button, Card } from 'react-bootstrap'
|
import { Badge, Card } from 'react-bootstrap'
|
||||||
import styles from '@/styles/wallet.module.css'
|
import styles from '@/styles/wallet.module.css'
|
||||||
import Plug from '@/svgs/plug.svg'
|
import Plug from '@/svgs/plug.svg'
|
||||||
import Gear from '@/svgs/settings-5-fill.svg'
|
import Gear from '@/svgs/settings-5-fill.svg'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import CancelButton from './cancel-button'
|
import { Status } from 'wallets'
|
||||||
import { SubmitButton } from './form'
|
|
||||||
import { Status } from './webln'
|
|
||||||
|
|
||||||
export const isConfigured = status => [Status.Enabled, Status.Locked, Status.Error, true].includes(status)
|
export default function WalletCard ({ wallet }) {
|
||||||
|
const { card: { title, badges } } = wallet
|
||||||
|
|
||||||
export function WalletCard ({ title, badges, provider, status }) {
|
|
||||||
const configured = isConfigured(status)
|
|
||||||
let indicator = styles.disabled
|
let indicator = styles.disabled
|
||||||
switch (status) {
|
switch (wallet.status) {
|
||||||
case Status.Enabled:
|
case Status.Enabled:
|
||||||
case true:
|
case true:
|
||||||
indicator = styles.success
|
indicator = styles.success
|
||||||
|
@ -42,35 +39,13 @@ export function WalletCard ({ title, badges, provider, status }) {
|
||||||
</Badge>)}
|
</Badge>)}
|
||||||
</Card.Subtitle>
|
</Card.Subtitle>
|
||||||
</Card.Body>
|
</Card.Body>
|
||||||
{provider &&
|
<Link href={`/settings/wallets/${wallet.name}`}>
|
||||||
<Link href={`/settings/wallets/${provider}`}>
|
<Card.Footer className={styles.attach}>
|
||||||
<Card.Footer className={styles.attach}>
|
{wallet.isConfigured
|
||||||
{configured
|
? <>configure<Gear width={14} height={14} /></>
|
||||||
? <>configure<Gear width={14} height={14} /></>
|
: <>attach<Plug width={14} height={14} /></>}
|
||||||
: <>attach<Plug width={14} height={14} /></>}
|
</Card.Footer>
|
||||||
</Card.Footer>
|
</Link>
|
||||||
</Link>}
|
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WalletButtonBar ({
|
|
||||||
status, disable,
|
|
||||||
className, children, onDelete, onCancel, hasCancel = true,
|
|
||||||
createText = 'attach', deleteText = 'detach', editText = 'save'
|
|
||||||
}) {
|
|
||||||
const configured = isConfigured(status)
|
|
||||||
return (
|
|
||||||
<div className={`mt-3 ${className}`}>
|
|
||||||
<div className='d-flex justify-content-between'>
|
|
||||||
{configured &&
|
|
||||||
<Button onClick={onDelete} variant='grey-medium'>{deleteText}</Button>}
|
|
||||||
{children}
|
|
||||||
<div className='d-flex align-items-center ms-auto'>
|
|
||||||
{hasCancel && <CancelButton onClick={onCancel} />}
|
|
||||||
<SubmitButton variant='primary' disabled={disable}>{configured ? editText : createText}</SubmitButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
|
@ -0,0 +1,274 @@
|
||||||
|
import LogMessage from './log-message'
|
||||||
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import styles from '@/styles/log.module.css'
|
||||||
|
import { Button } from 'react-bootstrap'
|
||||||
|
import { useToast } from './toast'
|
||||||
|
import { useShowModal } from './modal'
|
||||||
|
import { WALLET_LOGS } from '@/fragments/wallet'
|
||||||
|
import { getWalletByType } from 'wallets'
|
||||||
|
import { gql, useMutation, useQuery } from '@apollo/client'
|
||||||
|
import { useMe } from './me'
|
||||||
|
|
||||||
|
export function WalletLogs ({ wallet, embedded }) {
|
||||||
|
const logs = useWalletLogs(wallet)
|
||||||
|
|
||||||
|
const tableRef = useRef()
|
||||||
|
const showModal = useShowModal()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className='d-flex w-100 align-items-center mb-3'>
|
||||||
|
<span
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
className='text-muted fw-bold nav-link ms-auto' onClick={() => {
|
||||||
|
showModal(onClose => <DeleteWalletLogsObstacle wallet={wallet} onClose={onClose} />)
|
||||||
|
}}
|
||||||
|
>clear logs
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div ref={tableRef} className={`${styles.logTable} ${embedded ? styles.embedded : ''}`}>
|
||||||
|
{logs.length === 0 && <div className='w-100 text-center'>empty</div>}
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
{logs.map((log, i) => <LogMessage key={i} {...log} />)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div className='w-100 text-center'>------ start of logs ------</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeleteWalletLogsObstacle ({ wallet, onClose }) {
|
||||||
|
const toaster = useToast()
|
||||||
|
const { deleteLogs } = useWalletLogger(wallet)
|
||||||
|
|
||||||
|
const prompt = `Do you really want to delete all ${wallet ? '' : 'wallet'} logs ${wallet ? 'of this wallet' : ''}?`
|
||||||
|
return (
|
||||||
|
<div className='text-center'>
|
||||||
|
{prompt}
|
||||||
|
<div className='d-flex justify-center align-items-center mt-3 mx-auto'>
|
||||||
|
<span style={{ cursor: 'pointer' }} className='d-flex ms-auto text-muted fw-bold nav-link mx-3' onClick={onClose}>cancel</span>
|
||||||
|
<Button
|
||||||
|
className='d-flex me-auto mx-3' variant='danger'
|
||||||
|
onClick={
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
await deleteLogs()
|
||||||
|
onClose()
|
||||||
|
toaster.success('deleted wallet logs')
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
toaster.danger('failed to delete wallet logs')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const WalletLoggerContext = createContext()
|
||||||
|
const WalletLogsContext = createContext()
|
||||||
|
|
||||||
|
const initIndexedDB = async (dbName, storeName) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!window.indexedDB) {
|
||||||
|
return reject(new Error('IndexedDB not supported'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API/Using_IndexedDB
|
||||||
|
const request = window.indexedDB.open(dbName, 1)
|
||||||
|
|
||||||
|
let db
|
||||||
|
request.onupgradeneeded = () => {
|
||||||
|
// this only runs if version was changed during open
|
||||||
|
db = request.result
|
||||||
|
if (!db.objectStoreNames.contains(storeName)) {
|
||||||
|
const objectStore = db.createObjectStore(storeName, { autoIncrement: true })
|
||||||
|
objectStore.createIndex('ts', 'ts')
|
||||||
|
objectStore.createIndex('wallet_ts', ['wallet', 'ts'])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
// this gets called after onupgradeneeded finished
|
||||||
|
db = request.result
|
||||||
|
resolve(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
request.onerror = () => {
|
||||||
|
reject(new Error('failed to open IndexedDB'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WalletLoggerProvider = ({ children }) => {
|
||||||
|
const me = useMe()
|
||||||
|
const [logs, setLogs] = useState([])
|
||||||
|
let dbName = 'app:storage'
|
||||||
|
if (me) {
|
||||||
|
dbName = `${dbName}:${me.id}`
|
||||||
|
}
|
||||||
|
const idbStoreName = 'wallet_logs'
|
||||||
|
const idb = useRef()
|
||||||
|
const logQueue = useRef([])
|
||||||
|
|
||||||
|
useQuery(WALLET_LOGS, {
|
||||||
|
fetchPolicy: 'network-only',
|
||||||
|
// required to trigger onCompleted on refetches
|
||||||
|
notifyOnNetworkStatusChange: true,
|
||||||
|
onCompleted: ({ walletLogs }) => {
|
||||||
|
setLogs((prevLogs) => {
|
||||||
|
const existingIds = prevLogs.map(({ id }) => id)
|
||||||
|
const logs = walletLogs
|
||||||
|
.filter(({ id }) => !existingIds.includes(id))
|
||||||
|
.map(({ createdAt, wallet: walletType, ...log }) => {
|
||||||
|
return {
|
||||||
|
ts: +new Date(createdAt),
|
||||||
|
wallet: tag(getWalletByType(walletType)),
|
||||||
|
...log
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return [...prevLogs, ...logs].sort((a, b) => b.ts - a.ts)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const [deleteServerWalletLogs] = useMutation(
|
||||||
|
gql`
|
||||||
|
mutation deleteWalletLogs($wallet: String) {
|
||||||
|
deleteWalletLogs(wallet: $wallet)
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
onCompleted: (_, { variables: { wallet: walletType } }) => {
|
||||||
|
setLogs((logs) => {
|
||||||
|
return logs.filter(l => walletType ? l.wallet !== getWalletByType(walletType).name : false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const saveLog = useCallback((log) => {
|
||||||
|
if (!idb.current) {
|
||||||
|
// IDB may not be ready yet
|
||||||
|
return logQueue.current.push(log)
|
||||||
|
}
|
||||||
|
const tx = idb.current.transaction(idbStoreName, 'readwrite')
|
||||||
|
const request = tx.objectStore(idbStoreName).add(log)
|
||||||
|
request.onerror = () => console.error('failed to save log:', log)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
initIndexedDB(dbName, idbStoreName)
|
||||||
|
.then(db => {
|
||||||
|
idb.current = db
|
||||||
|
|
||||||
|
// load all logs from IDB
|
||||||
|
const tx = idb.current.transaction(idbStoreName, 'readonly')
|
||||||
|
const store = tx.objectStore(idbStoreName)
|
||||||
|
const index = store.index('ts')
|
||||||
|
const request = index.getAll()
|
||||||
|
request.onsuccess = () => {
|
||||||
|
let logs = request.result
|
||||||
|
setLogs((prevLogs) => {
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
// in dev mode, useEffect runs twice, so we filter out duplicates here
|
||||||
|
const existingIds = prevLogs.map(({ id }) => id)
|
||||||
|
logs = logs.filter(({ id }) => !existingIds.includes(id))
|
||||||
|
}
|
||||||
|
// sort oldest first to keep same order as logs are appended
|
||||||
|
return [...prevLogs, ...logs].sort((a, b) => b.ts - a.ts)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// flush queued logs to IDB
|
||||||
|
logQueue.current.forEach(q => {
|
||||||
|
const isLog = !!q.wallet
|
||||||
|
if (isLog) saveLog(q)
|
||||||
|
})
|
||||||
|
|
||||||
|
logQueue.current = []
|
||||||
|
})
|
||||||
|
.catch(console.error)
|
||||||
|
return () => idb.current?.close()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const appendLog = useCallback((wallet, level, message) => {
|
||||||
|
const log = { wallet: tag(wallet), level, message, ts: +new Date() }
|
||||||
|
saveLog(log)
|
||||||
|
setLogs((prevLogs) => [log, ...prevLogs])
|
||||||
|
}, [saveLog])
|
||||||
|
|
||||||
|
const deleteLogs = useCallback(async (wallet) => {
|
||||||
|
if (!wallet || wallet.walletType) {
|
||||||
|
await deleteServerWalletLogs({ variables: { wallet: wallet?.walletType } })
|
||||||
|
}
|
||||||
|
if (!wallet || wallet.sendPayment) {
|
||||||
|
const tx = idb.current.transaction(idbStoreName, 'readwrite')
|
||||||
|
const objectStore = tx.objectStore(idbStoreName)
|
||||||
|
const idx = objectStore.index('wallet_ts')
|
||||||
|
const request = wallet ? idx.openCursor(window.IDBKeyRange.bound([tag(wallet), -Infinity], [tag(wallet), Infinity])) : idx.openCursor()
|
||||||
|
request.onsuccess = function (event) {
|
||||||
|
const cursor = event.target.result
|
||||||
|
if (cursor) {
|
||||||
|
cursor.delete()
|
||||||
|
cursor.continue()
|
||||||
|
} else {
|
||||||
|
// finished
|
||||||
|
setLogs((logs) => logs.filter(l => wallet ? l.wallet !== tag(wallet) : false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [me, setLogs])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WalletLogsContext.Provider value={logs}>
|
||||||
|
<WalletLoggerContext.Provider value={{ appendLog, deleteLogs }}>
|
||||||
|
{children}
|
||||||
|
</WalletLoggerContext.Provider>
|
||||||
|
</WalletLogsContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWalletLogger (wallet) {
|
||||||
|
const { appendLog, deleteLogs: innerDeleteLogs } = useContext(WalletLoggerContext)
|
||||||
|
|
||||||
|
const log = useCallback(level => message => {
|
||||||
|
if (!wallet) {
|
||||||
|
console.error('cannot log: no wallet set')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// don't store logs for receiving wallets on client since logs are stored on server
|
||||||
|
if (wallet.walletType) return
|
||||||
|
|
||||||
|
// TODO:
|
||||||
|
// also send this to us if diagnostics was enabled,
|
||||||
|
// very similar to how the service worker logger works.
|
||||||
|
appendLog(wallet, level, message)
|
||||||
|
console[level !== 'error' ? 'info' : 'error'](`[${tag(wallet)}]`, message)
|
||||||
|
}, [appendLog, wallet])
|
||||||
|
|
||||||
|
const logger = useMemo(() => ({
|
||||||
|
ok: (...message) => log('ok')(message.join(' ')),
|
||||||
|
info: (...message) => log('info')(message.join(' ')),
|
||||||
|
error: (...message) => log('error')(message.join(' '))
|
||||||
|
}), [log, wallet?.name])
|
||||||
|
|
||||||
|
const deleteLogs = useCallback((w) => innerDeleteLogs(w || wallet), [innerDeleteLogs, wallet])
|
||||||
|
|
||||||
|
return { logger, deleteLogs }
|
||||||
|
}
|
||||||
|
|
||||||
|
function tag (wallet) {
|
||||||
|
return wallet?.shortName || wallet?.name
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWalletLogs (wallet) {
|
||||||
|
const logs = useContext(WalletLogsContext)
|
||||||
|
return logs.filter(l => !wallet || l.wallet === tag(wallet))
|
||||||
|
}
|
|
@ -1,121 +0,0 @@
|
||||||
import { useRouter } from 'next/router'
|
|
||||||
import LogMessage from './log-message'
|
|
||||||
import { useWalletLogger, useWalletLogs } from './logger'
|
|
||||||
import { useEffect, useRef, useState } from 'react'
|
|
||||||
import { Checkbox, Form } from './form'
|
|
||||||
import { useField } from 'formik'
|
|
||||||
import styles from '@/styles/log.module.css'
|
|
||||||
import { Button } from 'react-bootstrap'
|
|
||||||
import { useToast } from './toast'
|
|
||||||
import { useShowModal } from './modal'
|
|
||||||
|
|
||||||
const FollowCheckbox = ({ value, ...props }) => {
|
|
||||||
const [,, helpers] = useField(props.name)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
helpers.setValue(value)
|
|
||||||
}, [value])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Checkbox {...props} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function WalletLogs ({ wallet, embedded }) {
|
|
||||||
const logs = useWalletLogs(wallet)
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const { follow: defaultFollow } = router.query
|
|
||||||
const [follow, setFollow] = useState(defaultFollow ?? true)
|
|
||||||
const tableRef = useRef()
|
|
||||||
const scrollY = useRef()
|
|
||||||
const showModal = useShowModal()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (follow) {
|
|
||||||
tableRef.current?.scroll({ top: tableRef.current.scrollHeight, behavior: 'smooth' })
|
|
||||||
}
|
|
||||||
}, [logs, follow])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function onScroll (e) {
|
|
||||||
const y = e.target.scrollTop
|
|
||||||
|
|
||||||
const down = y - scrollY.current >= -1
|
|
||||||
if (!!scrollY.current && !down) {
|
|
||||||
setFollow(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxY = e.target.scrollHeight - e.target.clientHeight
|
|
||||||
const dY = maxY - y
|
|
||||||
const isBottom = dY >= -1 && dY <= 1
|
|
||||||
if (isBottom) {
|
|
||||||
setFollow(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
scrollY.current = y
|
|
||||||
}
|
|
||||||
tableRef.current?.addEventListener('scroll', onScroll)
|
|
||||||
return () => tableRef.current?.removeEventListener('scroll', onScroll)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className='d-flex w-100 align-items-center mb-3'>
|
|
||||||
<Form initial={{ follow: true }}>
|
|
||||||
<FollowCheckbox
|
|
||||||
label='follow logs' name='follow' value={follow}
|
|
||||||
handleChange={setFollow} groupClassName='mb-0'
|
|
||||||
/>
|
|
||||||
</Form>
|
|
||||||
<span
|
|
||||||
style={{ cursor: 'pointer' }}
|
|
||||||
className='text-muted fw-bold nav-link' onClick={() => {
|
|
||||||
showModal(onClose => <DeleteWalletLogsObstacle wallet={wallet} onClose={onClose} />)
|
|
||||||
}}
|
|
||||||
>clear
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div ref={tableRef} className={`${styles.logTable} ${embedded ? styles.embedded : ''}`}>
|
|
||||||
<div className='w-100 text-center'>------ start of logs ------</div>
|
|
||||||
{logs.length === 0 && <div className='w-100 text-center'>empty</div>}
|
|
||||||
<table>
|
|
||||||
<tbody>
|
|
||||||
{logs.map((log, i) => <LogMessage key={i} {...log} />)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DeleteWalletLogsObstacle ({ wallet, onClose }) {
|
|
||||||
const toaster = useToast()
|
|
||||||
const { deleteLogs } = useWalletLogger(wallet)
|
|
||||||
|
|
||||||
const prompt = `Do you really want to delete all ${wallet ? '' : 'wallet'} logs ${wallet ? 'of this wallet' : ''}?`
|
|
||||||
return (
|
|
||||||
<div className='text-center'>
|
|
||||||
{prompt}
|
|
||||||
<div className='d-flex justify-center align-items-center mt-3 mx-auto'>
|
|
||||||
<span style={{ cursor: 'pointer' }} className='d-flex ms-auto text-muted fw-bold nav-link mx-3' onClick={onClose}>cancel</span>
|
|
||||||
<Button
|
|
||||||
className='d-flex me-auto mx-3' variant='danger'
|
|
||||||
onClick={
|
|
||||||
async () => {
|
|
||||||
try {
|
|
||||||
await deleteLogs()
|
|
||||||
onClose()
|
|
||||||
toaster.success('deleted wallet logs')
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
toaster.danger('failed to delete wallet logs')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>delete
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,142 +0,0 @@
|
||||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
|
||||||
import { LNbitsProvider, useLNbits } from './lnbits'
|
|
||||||
import { NWCProvider, useNWC } from './nwc'
|
|
||||||
import { LNCProvider, useLNC } from './lnc'
|
|
||||||
|
|
||||||
const WebLNContext = createContext({})
|
|
||||||
|
|
||||||
const isEnabled = p => [Status.Enabled, Status.Locked].includes(p?.status)
|
|
||||||
|
|
||||||
const syncProvider = (array, provider) => {
|
|
||||||
const idx = array.findIndex(({ name }) => provider.name === name)
|
|
||||||
const enabled = isEnabled(provider)
|
|
||||||
if (idx === -1) {
|
|
||||||
// add provider to end if enabled
|
|
||||||
return enabled ? [...array, provider] : array
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
...array.slice(0, idx),
|
|
||||||
// remove provider if not enabled
|
|
||||||
...enabled ? [provider] : [],
|
|
||||||
...array.slice(idx + 1)
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
const storageKey = 'webln:providers'
|
|
||||||
|
|
||||||
export const Status = {
|
|
||||||
Initialized: 'Initialized',
|
|
||||||
Enabled: 'Enabled',
|
|
||||||
Locked: 'Locked',
|
|
||||||
Error: 'Error'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function migrateLocalStorage (oldStorageKey, newStorageKey) {
|
|
||||||
const item = window.localStorage.getItem(oldStorageKey)
|
|
||||||
if (item) {
|
|
||||||
window.localStorage.setItem(newStorageKey, item)
|
|
||||||
window.localStorage.removeItem(oldStorageKey)
|
|
||||||
}
|
|
||||||
return item
|
|
||||||
}
|
|
||||||
|
|
||||||
function RawWebLNProvider ({ children }) {
|
|
||||||
const lnbits = useLNbits()
|
|
||||||
const nwc = useNWC()
|
|
||||||
const lnc = useLNC()
|
|
||||||
const availableProviders = [lnbits, nwc, lnc]
|
|
||||||
const [enabledProviders, setEnabledProviders] = useState([])
|
|
||||||
|
|
||||||
// restore order on page reload
|
|
||||||
useEffect(() => {
|
|
||||||
const storedOrder = window.localStorage.getItem(storageKey)
|
|
||||||
if (!storedOrder) return
|
|
||||||
const providerNames = JSON.parse(storedOrder)
|
|
||||||
setEnabledProviders(providers => {
|
|
||||||
return providerNames.map(name => {
|
|
||||||
for (const p of availableProviders) {
|
|
||||||
if (p.name === name) return p
|
|
||||||
}
|
|
||||||
console.warn(`Stored provider with name ${name} not available`)
|
|
||||||
return null
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// keep list in sync with underlying providers
|
|
||||||
useEffect(() => {
|
|
||||||
setEnabledProviders(providers => {
|
|
||||||
// Sync existing provider state with new provider state
|
|
||||||
// in the list while keeping the order they are in.
|
|
||||||
// If provider does not exist but is enabled, it is just added to the end of the list.
|
|
||||||
// This can be the case if we're syncing from a page reload
|
|
||||||
// where the providers are initially not enabled.
|
|
||||||
// If provider is no longer enabled, it is removed from the list.
|
|
||||||
const isInitialized = p => [Status.Enabled, Status.Locked, Status.Initialized].includes(p.status)
|
|
||||||
const newProviders = availableProviders.filter(isInitialized).reduce(syncProvider, providers)
|
|
||||||
const newOrder = newProviders.map(({ name }) => name)
|
|
||||||
window.localStorage.setItem(storageKey, JSON.stringify(newOrder))
|
|
||||||
return newProviders
|
|
||||||
})
|
|
||||||
}, [...availableProviders])
|
|
||||||
|
|
||||||
// first provider in list is the default provider
|
|
||||||
// TODO: implement fallbacks via provider priority
|
|
||||||
const provider = enabledProviders[0]
|
|
||||||
|
|
||||||
const setProvider = useCallback((defaultProvider) => {
|
|
||||||
// move provider to the start to set it as default
|
|
||||||
setEnabledProviders(providers => {
|
|
||||||
const idx = providers.findIndex(({ name }) => defaultProvider.name === name)
|
|
||||||
if (idx === -1) {
|
|
||||||
console.warn(`tried to set unenabled provider ${defaultProvider.name} as default`)
|
|
||||||
return providers
|
|
||||||
}
|
|
||||||
return [defaultProvider, ...providers.slice(0, idx), ...providers.slice(idx + 1)]
|
|
||||||
})
|
|
||||||
}, [setEnabledProviders])
|
|
||||||
|
|
||||||
const clearConfig = useCallback(async () => {
|
|
||||||
lnbits.clearConfig()
|
|
||||||
nwc.clearConfig()
|
|
||||||
await lnc.clearConfig()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const value = useMemo(() => ({
|
|
||||||
provider: isEnabled(provider)
|
|
||||||
? { name: provider.name, sendPayment: provider.sendPayment }
|
|
||||||
: null,
|
|
||||||
enabledProviders,
|
|
||||||
setProvider,
|
|
||||||
clearConfig
|
|
||||||
}), [provider, enabledProviders, setProvider])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<WebLNContext.Provider value={value}>
|
|
||||||
{children}
|
|
||||||
</WebLNContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WebLNProvider ({ children }) {
|
|
||||||
return (
|
|
||||||
<LNbitsProvider>
|
|
||||||
<NWCProvider>
|
|
||||||
<LNCProvider>
|
|
||||||
<RawWebLNProvider>
|
|
||||||
{children}
|
|
||||||
</RawWebLNProvider>
|
|
||||||
</LNCProvider>
|
|
||||||
</NWCProvider>
|
|
||||||
</LNbitsProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useWebLN () {
|
|
||||||
const { provider } = useContext(WebLNContext)
|
|
||||||
return provider
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useWebLNConfigurator () {
|
|
||||||
return useContext(WebLNContext)
|
|
||||||
}
|
|
|
@ -1,210 +0,0 @@
|
||||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
|
||||||
import { useWalletLogger } from '../logger'
|
|
||||||
import { Status, migrateLocalStorage } from '.'
|
|
||||||
import { bolt11Tags } from '@/lib/bolt11'
|
|
||||||
import { Wallet } from '@/lib/constants'
|
|
||||||
import { useMe } from '../me'
|
|
||||||
|
|
||||||
// Reference: https://github.com/getAlby/bitcoin-connect/blob/v3.2.0-alpha/src/connectors/LnbitsConnector.ts
|
|
||||||
|
|
||||||
const LNbitsContext = createContext()
|
|
||||||
|
|
||||||
const getWallet = async (baseUrl, adminKey) => {
|
|
||||||
const url = baseUrl.replace(/\/+$/, '')
|
|
||||||
const path = '/api/v1/wallet'
|
|
||||||
|
|
||||||
const headers = new Headers()
|
|
||||||
headers.append('Accept', 'application/json')
|
|
||||||
headers.append('Content-Type', 'application/json')
|
|
||||||
headers.append('X-Api-Key', adminKey)
|
|
||||||
|
|
||||||
const res = await fetch(url + path, { method: 'GET', headers })
|
|
||||||
if (!res.ok) {
|
|
||||||
const errBody = await res.json()
|
|
||||||
throw new Error(errBody.detail)
|
|
||||||
}
|
|
||||||
const wallet = await res.json()
|
|
||||||
return wallet
|
|
||||||
}
|
|
||||||
|
|
||||||
const postPayment = async (baseUrl, adminKey, bolt11) => {
|
|
||||||
const url = baseUrl.replace(/\/+$/, '')
|
|
||||||
const path = '/api/v1/payments'
|
|
||||||
|
|
||||||
const headers = new Headers()
|
|
||||||
headers.append('Accept', 'application/json')
|
|
||||||
headers.append('Content-Type', 'application/json')
|
|
||||||
headers.append('X-Api-Key', adminKey)
|
|
||||||
|
|
||||||
const body = JSON.stringify({ bolt11, out: true })
|
|
||||||
|
|
||||||
const res = await fetch(url + path, { method: 'POST', headers, body })
|
|
||||||
if (!res.ok) {
|
|
||||||
const errBody = await res.json()
|
|
||||||
throw new Error(errBody.detail)
|
|
||||||
}
|
|
||||||
const payment = await res.json()
|
|
||||||
return payment
|
|
||||||
}
|
|
||||||
|
|
||||||
const getPayment = async (baseUrl, adminKey, paymentHash) => {
|
|
||||||
const url = baseUrl.replace(/\/+$/, '')
|
|
||||||
const path = `/api/v1/payments/${paymentHash}`
|
|
||||||
|
|
||||||
const headers = new Headers()
|
|
||||||
headers.append('Accept', 'application/json')
|
|
||||||
headers.append('Content-Type', 'application/json')
|
|
||||||
headers.append('X-Api-Key', adminKey)
|
|
||||||
|
|
||||||
const res = await fetch(url + path, { method: 'GET', headers })
|
|
||||||
if (!res.ok) {
|
|
||||||
const errBody = await res.json()
|
|
||||||
throw new Error(errBody.detail)
|
|
||||||
}
|
|
||||||
const payment = await res.json()
|
|
||||||
return payment
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LNbitsProvider ({ children }) {
|
|
||||||
const me = useMe()
|
|
||||||
const [url, setUrl] = useState('')
|
|
||||||
const [adminKey, setAdminKey] = useState('')
|
|
||||||
const [status, setStatus] = useState()
|
|
||||||
const { logger } = useWalletLogger(Wallet.LNbits)
|
|
||||||
|
|
||||||
let storageKey = 'webln:provider:lnbits'
|
|
||||||
if (me) {
|
|
||||||
storageKey = `${storageKey}:${me.id}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const getInfo = useCallback(async () => {
|
|
||||||
const response = await getWallet(url, adminKey)
|
|
||||||
return {
|
|
||||||
node: {
|
|
||||||
alias: response.name,
|
|
||||||
pubkey: ''
|
|
||||||
},
|
|
||||||
methods: [
|
|
||||||
'getInfo',
|
|
||||||
'getBalance',
|
|
||||||
'sendPayment'
|
|
||||||
],
|
|
||||||
version: '1.0',
|
|
||||||
supports: ['lightning']
|
|
||||||
}
|
|
||||||
}, [url, adminKey])
|
|
||||||
|
|
||||||
const sendPayment = useCallback(async (bolt11) => {
|
|
||||||
const hash = bolt11Tags(bolt11).payment_hash
|
|
||||||
logger.info('sending payment:', `payment_hash=${hash}`)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await postPayment(url, adminKey, bolt11)
|
|
||||||
const checkResponse = await getPayment(url, adminKey, response.payment_hash)
|
|
||||||
if (!checkResponse.preimage) {
|
|
||||||
throw new Error('No preimage')
|
|
||||||
}
|
|
||||||
const preimage = checkResponse.preimage
|
|
||||||
logger.ok('payment successful:', `payment_hash=${hash}`, `preimage=${preimage}`)
|
|
||||||
return { preimage }
|
|
||||||
} catch (err) {
|
|
||||||
logger.error('payment failed:', `payment_hash=${hash}`, err.message || err.toString?.())
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}, [logger, url, adminKey])
|
|
||||||
|
|
||||||
const loadConfig = useCallback(async () => {
|
|
||||||
let configStr = window.localStorage.getItem(storageKey)
|
|
||||||
setStatus(Status.Initialized)
|
|
||||||
if (!configStr) {
|
|
||||||
if (me) {
|
|
||||||
// backwards compatibility: try old storageKey
|
|
||||||
const oldStorageKey = storageKey.split(':').slice(0, -1).join(':')
|
|
||||||
configStr = migrateLocalStorage(oldStorageKey, storageKey)
|
|
||||||
}
|
|
||||||
if (!configStr) {
|
|
||||||
logger.info('no existing config found')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = JSON.parse(configStr)
|
|
||||||
|
|
||||||
const { url, adminKey } = config
|
|
||||||
setUrl(url)
|
|
||||||
setAdminKey(adminKey)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
'loaded wallet config: ' +
|
|
||||||
'adminKey=****** ' +
|
|
||||||
`url=${url}`)
|
|
||||||
|
|
||||||
try {
|
|
||||||
// validate config by trying to fetch wallet
|
|
||||||
logger.info('trying to fetch wallet')
|
|
||||||
await getWallet(url, adminKey)
|
|
||||||
logger.ok('wallet found')
|
|
||||||
setStatus(Status.Enabled)
|
|
||||||
logger.ok('wallet enabled')
|
|
||||||
} catch (err) {
|
|
||||||
logger.error('invalid config:', err)
|
|
||||||
setStatus(Status.Error)
|
|
||||||
logger.info('wallet disabled')
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}, [me, logger])
|
|
||||||
|
|
||||||
const saveConfig = useCallback(async (config) => {
|
|
||||||
// immediately store config so it's not lost even if config is invalid
|
|
||||||
setUrl(config.url)
|
|
||||||
setAdminKey(config.adminKey)
|
|
||||||
|
|
||||||
// XXX This is insecure, XSS vulns could lead to loss of funds!
|
|
||||||
// -> check how mutiny encrypts their wallet and/or check if we can leverage web workers
|
|
||||||
// https://thenewstack.io/leveraging-web-workers-to-safely-store-access-tokens/
|
|
||||||
window.localStorage.setItem(storageKey, JSON.stringify(config))
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
'saved wallet config: ' +
|
|
||||||
'adminKey=****** ' +
|
|
||||||
`url=${config.url}`)
|
|
||||||
|
|
||||||
try {
|
|
||||||
// validate config by trying to fetch wallet
|
|
||||||
logger.info('trying to fetch wallet')
|
|
||||||
await getWallet(config.url, config.adminKey)
|
|
||||||
logger.ok('wallet found')
|
|
||||||
setStatus(Status.Enabled)
|
|
||||||
logger.ok('wallet enabled')
|
|
||||||
} catch (err) {
|
|
||||||
logger.error('invalid config:', err)
|
|
||||||
setStatus(Status.Error)
|
|
||||||
logger.info('wallet disabled')
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const clearConfig = useCallback(() => {
|
|
||||||
window.localStorage.removeItem(storageKey)
|
|
||||||
setUrl('')
|
|
||||||
setAdminKey('')
|
|
||||||
setStatus(undefined)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadConfig().catch(console.error)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const value = useMemo(
|
|
||||||
() => ({ name: 'LNbits', url, adminKey, status, saveConfig, clearConfig, getInfo, sendPayment }),
|
|
||||||
[url, adminKey, status, saveConfig, clearConfig, getInfo, sendPayment])
|
|
||||||
return (
|
|
||||||
<LNbitsContext.Provider value={value}>
|
|
||||||
{children}
|
|
||||||
</LNbitsContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useLNbits () {
|
|
||||||
return useContext(LNbitsContext)
|
|
||||||
}
|
|
|
@ -1,215 +0,0 @@
|
||||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
|
||||||
import { useWalletLogger } from '../logger'
|
|
||||||
import { Status, migrateLocalStorage } from '.'
|
|
||||||
import { bolt11Tags } from '@/lib/bolt11'
|
|
||||||
import useModal from '../modal'
|
|
||||||
import { Form, PasswordInput, SubmitButton } from '../form'
|
|
||||||
import CancelButton from '../cancel-button'
|
|
||||||
import { Mutex } from 'async-mutex'
|
|
||||||
import { Wallet } from '@/lib/constants'
|
|
||||||
import { useMe } from '../me'
|
|
||||||
import { InvoiceCanceledError, InvoiceExpiredError } from '@/components/payment'
|
|
||||||
|
|
||||||
const LNCContext = createContext()
|
|
||||||
const mutex = new Mutex()
|
|
||||||
|
|
||||||
async function getLNC ({ me }) {
|
|
||||||
if (window.lnc) return window.lnc
|
|
||||||
const { default: LNC } = await import('@lightninglabs/lnc-web')
|
|
||||||
// backwards compatibility: migrate to new storage key
|
|
||||||
if (me) migrateLocalStorage('lnc-web:default', `lnc-web:stacker:${me.id}`)
|
|
||||||
window.lnc = new LNC({ namespace: me?.id ? `stacker:${me.id}` : undefined })
|
|
||||||
return window.lnc
|
|
||||||
}
|
|
||||||
|
|
||||||
// default password if the user hasn't set one
|
|
||||||
export const XXX_DEFAULT_PASSWORD = 'password'
|
|
||||||
|
|
||||||
function validateNarrowPerms (lnc) {
|
|
||||||
if (!lnc.hasPerms('lnrpc.Lightning.SendPaymentSync')) {
|
|
||||||
throw new Error('missing permission: lnrpc.Lightning.SendPaymentSync')
|
|
||||||
}
|
|
||||||
if (lnc.hasPerms('lnrpc.Lightning.SendCoins')) {
|
|
||||||
throw new Error('too broad permission: lnrpc.Wallet.SendCoins')
|
|
||||||
}
|
|
||||||
// TODO: need to check for more narrow permissions
|
|
||||||
// blocked by https://github.com/lightninglabs/lnc-web/issues/112
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LNCProvider ({ children }) {
|
|
||||||
const me = useMe()
|
|
||||||
const { logger } = useWalletLogger(Wallet.LNC)
|
|
||||||
const [config, setConfig] = useState({})
|
|
||||||
const [lnc, setLNC] = useState()
|
|
||||||
const [status, setStatus] = useState()
|
|
||||||
const [modal, showModal] = useModal()
|
|
||||||
|
|
||||||
const getInfo = useCallback(async () => {
|
|
||||||
logger.info('getInfo called')
|
|
||||||
return await lnc.lightning.getInfo()
|
|
||||||
}, [logger, lnc])
|
|
||||||
|
|
||||||
const unlock = useCallback(async (connect) => {
|
|
||||||
if (status === Status.Enabled) return config.password
|
|
||||||
|
|
||||||
return await new Promise((resolve, reject) => {
|
|
||||||
const cancelAndReject = async () => {
|
|
||||||
reject(new Error('password canceled'))
|
|
||||||
}
|
|
||||||
showModal(onClose => {
|
|
||||||
return (
|
|
||||||
<Form
|
|
||||||
initial={{
|
|
||||||
password: ''
|
|
||||||
}}
|
|
||||||
onSubmit={async (values) => {
|
|
||||||
try {
|
|
||||||
lnc.credentials.password = values?.password
|
|
||||||
setStatus(Status.Enabled)
|
|
||||||
setConfig({ pairingPhrase: lnc.credentials.pairingPhrase, password: values.password })
|
|
||||||
logger.ok('wallet enabled')
|
|
||||||
onClose()
|
|
||||||
resolve(values.password)
|
|
||||||
} catch (err) {
|
|
||||||
logger.error('failed attempt to unlock wallet', err)
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h4 className='text-center mb-3'>Unlock LNC</h4>
|
|
||||||
<PasswordInput
|
|
||||||
label='password'
|
|
||||||
name='password'
|
|
||||||
/>
|
|
||||||
<div className='mt-5 d-flex justify-content-between'>
|
|
||||||
<CancelButton onClick={() => { onClose(); cancelAndReject() }} />
|
|
||||||
<SubmitButton variant='primary'>unlock</SubmitButton>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
)
|
|
||||||
}, { onClose: cancelAndReject })
|
|
||||||
})
|
|
||||||
}, [logger, showModal, setConfig, lnc, status])
|
|
||||||
|
|
||||||
const sendPayment = useCallback(async (bolt11) => {
|
|
||||||
const hash = bolt11Tags(bolt11).payment_hash
|
|
||||||
logger.info('sending payment:', `payment_hash=${hash}`)
|
|
||||||
|
|
||||||
return await mutex.runExclusive(async () => {
|
|
||||||
try {
|
|
||||||
const password = await unlock()
|
|
||||||
// credentials need to be decrypted before connecting after a disconnect
|
|
||||||
lnc.credentials.password = password || XXX_DEFAULT_PASSWORD
|
|
||||||
await lnc.connect()
|
|
||||||
const { paymentError, paymentPreimage: preimage } =
|
|
||||||
await lnc.lnd.lightning.sendPaymentSync({ payment_request: bolt11 })
|
|
||||||
|
|
||||||
if (paymentError) throw new Error(paymentError)
|
|
||||||
if (!preimage) throw new Error('No preimage in response')
|
|
||||||
|
|
||||||
logger.ok('payment successful:', `payment_hash=${hash}`, `preimage=${preimage}`)
|
|
||||||
return { preimage }
|
|
||||||
} catch (err) {
|
|
||||||
const msg = err.message || err.toString?.()
|
|
||||||
logger.error('payment failed:', `payment_hash=${hash}`, msg)
|
|
||||||
if (msg.includes('invoice expired')) {
|
|
||||||
throw new InvoiceExpiredError(hash)
|
|
||||||
}
|
|
||||||
if (msg.includes('canceled')) {
|
|
||||||
throw new InvoiceCanceledError(hash)
|
|
||||||
}
|
|
||||||
throw err
|
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
lnc.disconnect()
|
|
||||||
logger.info('disconnecting after:', `payment_hash=${hash}`)
|
|
||||||
// wait for lnc to disconnect before releasing the mutex
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
let counter = 0
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
if (lnc.isConnected) {
|
|
||||||
if (counter++ > 100) {
|
|
||||||
logger.error('failed to disconnect from lnc')
|
|
||||||
clearInterval(interval)
|
|
||||||
reject(new Error('failed to disconnect from lnc'))
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
clearInterval(interval)
|
|
||||||
resolve()
|
|
||||||
})
|
|
||||||
}, 50)
|
|
||||||
} catch (err) {
|
|
||||||
logger.error('failed to disconnect from lnc', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}, [logger, lnc, unlock])
|
|
||||||
|
|
||||||
const saveConfig = useCallback(async config => {
|
|
||||||
setConfig(config)
|
|
||||||
|
|
||||||
try {
|
|
||||||
lnc.credentials.pairingPhrase = config.pairingPhrase
|
|
||||||
await lnc.connect()
|
|
||||||
await validateNarrowPerms(lnc)
|
|
||||||
lnc.credentials.password = config?.password || XXX_DEFAULT_PASSWORD
|
|
||||||
setStatus(Status.Enabled)
|
|
||||||
logger.ok('wallet enabled')
|
|
||||||
} catch (err) {
|
|
||||||
logger.error('invalid config:', err)
|
|
||||||
setStatus(Status.Error)
|
|
||||||
logger.info('wallet disabled')
|
|
||||||
throw err
|
|
||||||
} finally {
|
|
||||||
lnc.disconnect()
|
|
||||||
}
|
|
||||||
}, [logger, lnc])
|
|
||||||
|
|
||||||
const clearConfig = useCallback(async () => {
|
|
||||||
await lnc.credentials.clear(false)
|
|
||||||
if (lnc.isConnected) lnc.disconnect()
|
|
||||||
setStatus(undefined)
|
|
||||||
setConfig({})
|
|
||||||
logger.info('cleared config')
|
|
||||||
}, [logger, lnc])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const lnc = await getLNC({ me })
|
|
||||||
setLNC(lnc)
|
|
||||||
setStatus(Status.Initialized)
|
|
||||||
if (lnc.credentials.isPaired) {
|
|
||||||
try {
|
|
||||||
// try the default password
|
|
||||||
lnc.credentials.password = XXX_DEFAULT_PASSWORD
|
|
||||||
} catch (err) {
|
|
||||||
setStatus(Status.Locked)
|
|
||||||
logger.info('wallet needs password before enabling')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setStatus(Status.Enabled)
|
|
||||||
setConfig({ pairingPhrase: lnc.credentials.pairingPhrase, password: lnc.credentials.password })
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logger.error('wallet could not be loaded:', err)
|
|
||||||
setStatus(Status.Error)
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
}, [me, setStatus, setConfig, logger])
|
|
||||||
|
|
||||||
const value = useMemo(
|
|
||||||
() => ({ name: 'lnc', status, unlock, getInfo, sendPayment, config, saveConfig, clearConfig }),
|
|
||||||
[status, unlock, getInfo, sendPayment, config, saveConfig, clearConfig])
|
|
||||||
return (
|
|
||||||
<LNCContext.Provider value={value}>
|
|
||||||
{children}
|
|
||||||
{modal}
|
|
||||||
</LNCContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useLNC () {
|
|
||||||
return useContext(LNCContext)
|
|
||||||
}
|
|
|
@ -1,287 +0,0 @@
|
||||||
// https://github.com/getAlby/js-sdk/blob/master/src/webln/NostrWeblnProvider.ts
|
|
||||||
|
|
||||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
|
||||||
import { Relay, finalizeEvent, nip04 } from 'nostr-tools'
|
|
||||||
import { parseNwcUrl } from '@/lib/url'
|
|
||||||
import { useWalletLogger } from '../logger'
|
|
||||||
import { Status, migrateLocalStorage } from '.'
|
|
||||||
import { bolt11Tags } from '@/lib/bolt11'
|
|
||||||
import { JIT_INVOICE_TIMEOUT_MS, Wallet } from '@/lib/constants'
|
|
||||||
import { useMe } from '../me'
|
|
||||||
import { InvoiceExpiredError } from '../payment'
|
|
||||||
|
|
||||||
const NWCContext = createContext()
|
|
||||||
|
|
||||||
export function NWCProvider ({ children }) {
|
|
||||||
const me = useMe()
|
|
||||||
const [nwcUrl, setNwcUrl] = useState('')
|
|
||||||
const [walletPubkey, setWalletPubkey] = useState()
|
|
||||||
const [relayUrl, setRelayUrl] = useState()
|
|
||||||
const [secret, setSecret] = useState()
|
|
||||||
const [status, setStatus] = useState()
|
|
||||||
const { logger } = useWalletLogger(Wallet.NWC)
|
|
||||||
|
|
||||||
let storageKey = 'webln:provider:nwc'
|
|
||||||
if (me) {
|
|
||||||
storageKey = `${storageKey}:${me.id}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const getInfo = useCallback(async (relayUrl, walletPubkey) => {
|
|
||||||
logger.info(`requesting info event from ${relayUrl}`)
|
|
||||||
|
|
||||||
let relay
|
|
||||||
try {
|
|
||||||
relay = await Relay.connect(relayUrl)
|
|
||||||
logger.ok(`connected to ${relayUrl}`)
|
|
||||||
} catch (err) {
|
|
||||||
const msg = `failed to connect to ${relayUrl}`
|
|
||||||
logger.error(msg)
|
|
||||||
throw new Error(msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await new Promise((resolve, reject) => {
|
|
||||||
const timeout = 5000
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
const msg = 'timeout waiting for info event'
|
|
||||||
logger.error(msg)
|
|
||||||
reject(new Error(msg))
|
|
||||||
}, timeout)
|
|
||||||
|
|
||||||
let found = false
|
|
||||||
relay.subscribe([
|
|
||||||
{
|
|
||||||
kinds: [13194],
|
|
||||||
authors: [walletPubkey]
|
|
||||||
}
|
|
||||||
], {
|
|
||||||
onevent (event) {
|
|
||||||
clearTimeout(timer)
|
|
||||||
found = true
|
|
||||||
logger.ok(`received info event from ${relayUrl}`)
|
|
||||||
resolve(event)
|
|
||||||
},
|
|
||||||
onclose (reason) {
|
|
||||||
clearTimeout(timer)
|
|
||||||
if (!['closed by caller', 'relay connection closed by us'].includes(reason)) {
|
|
||||||
// only log if not closed by us (caller)
|
|
||||||
const msg = 'connection closed: ' + (reason || 'unknown reason')
|
|
||||||
logger.error(msg)
|
|
||||||
reject(new Error(msg))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
oneose () {
|
|
||||||
clearTimeout(timer)
|
|
||||||
if (!found) {
|
|
||||||
const msg = 'EOSE received without info event'
|
|
||||||
logger.error(msg)
|
|
||||||
reject(new Error(msg))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
relay?.close()?.catch()
|
|
||||||
if (relay) logger.info(`closed connection to ${relayUrl}`)
|
|
||||||
}
|
|
||||||
}, [logger])
|
|
||||||
|
|
||||||
const validateParams = useCallback(async ({ relayUrl, walletPubkey }) => {
|
|
||||||
// validate connection by fetching info event
|
|
||||||
// function needs to throw an error for formik validation to fail
|
|
||||||
const event = await getInfo(relayUrl, walletPubkey)
|
|
||||||
const supported = event.content.split(/[\s,]+/) // handle both spaces and commas
|
|
||||||
logger.info('supported methods:', supported)
|
|
||||||
if (!supported.includes('pay_invoice')) {
|
|
||||||
const msg = 'wallet does not support pay_invoice'
|
|
||||||
logger.error(msg)
|
|
||||||
throw new Error(msg)
|
|
||||||
}
|
|
||||||
logger.ok('wallet supports pay_invoice')
|
|
||||||
}, [logger])
|
|
||||||
|
|
||||||
const loadConfig = useCallback(async () => {
|
|
||||||
let configStr = window.localStorage.getItem(storageKey)
|
|
||||||
setStatus(Status.Initialized)
|
|
||||||
if (!configStr) {
|
|
||||||
if (me) {
|
|
||||||
// backwards compatibility: try old storageKey
|
|
||||||
const oldStorageKey = storageKey.split(':').slice(0, -1).join(':')
|
|
||||||
configStr = migrateLocalStorage(oldStorageKey, storageKey)
|
|
||||||
}
|
|
||||||
if (!configStr) {
|
|
||||||
logger.info('no existing config found')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = JSON.parse(configStr)
|
|
||||||
|
|
||||||
const { nwcUrl } = config
|
|
||||||
setNwcUrl(nwcUrl)
|
|
||||||
|
|
||||||
const params = parseNwcUrl(nwcUrl)
|
|
||||||
setRelayUrl(params.relayUrl)
|
|
||||||
setWalletPubkey(params.walletPubkey)
|
|
||||||
setSecret(params.secret)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
'loaded wallet config: ' +
|
|
||||||
'secret=****** ' +
|
|
||||||
`pubkey=${params.walletPubkey.slice(0, 6)}..${params.walletPubkey.slice(-6)} ` +
|
|
||||||
`relay=${params.relayUrl}`)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await validateParams(params)
|
|
||||||
setStatus(Status.Enabled)
|
|
||||||
logger.ok('wallet enabled')
|
|
||||||
} catch (err) {
|
|
||||||
logger.error('invalid config:', err)
|
|
||||||
setStatus(Status.Error)
|
|
||||||
logger.info('wallet disabled')
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}, [me, validateParams, logger])
|
|
||||||
|
|
||||||
const saveConfig = useCallback(async (config) => {
|
|
||||||
// immediately store config so it's not lost even if config is invalid
|
|
||||||
const { nwcUrl } = config
|
|
||||||
setNwcUrl(nwcUrl)
|
|
||||||
if (!nwcUrl) {
|
|
||||||
setStatus(undefined)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const params = parseNwcUrl(nwcUrl)
|
|
||||||
setRelayUrl(params.relayUrl)
|
|
||||||
setWalletPubkey(params.walletPubkey)
|
|
||||||
setSecret(params.secret)
|
|
||||||
|
|
||||||
// XXX Even though NWC allows to configure budget,
|
|
||||||
// this is definitely not ideal from a security perspective.
|
|
||||||
window.localStorage.setItem(storageKey, JSON.stringify(config))
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
'saved wallet config: ' +
|
|
||||||
'secret=****** ' +
|
|
||||||
`pubkey=${params.walletPubkey.slice(0, 6)}..${params.walletPubkey.slice(-6)} ` +
|
|
||||||
`relay=${params.relayUrl}`)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await validateParams(params)
|
|
||||||
setStatus(Status.Enabled)
|
|
||||||
logger.ok('wallet enabled')
|
|
||||||
} catch (err) {
|
|
||||||
logger.error('invalid config:', err)
|
|
||||||
setStatus(Status.Error)
|
|
||||||
logger.info('wallet disabled')
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}, [validateParams, logger])
|
|
||||||
|
|
||||||
const clearConfig = useCallback(() => {
|
|
||||||
window.localStorage.removeItem(storageKey)
|
|
||||||
setNwcUrl('')
|
|
||||||
setRelayUrl(undefined)
|
|
||||||
setWalletPubkey(undefined)
|
|
||||||
setSecret(undefined)
|
|
||||||
setStatus(undefined)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const sendPayment = useCallback(async (bolt11) => {
|
|
||||||
const hash = bolt11Tags(bolt11).payment_hash
|
|
||||||
logger.info('sending payment:', `payment_hash=${hash}`)
|
|
||||||
|
|
||||||
let relay
|
|
||||||
try {
|
|
||||||
relay = await Relay.connect(relayUrl)
|
|
||||||
logger.ok(`connected to ${relayUrl}`)
|
|
||||||
} catch (err) {
|
|
||||||
const msg = `failed to connect to ${relayUrl}`
|
|
||||||
logger.error(msg)
|
|
||||||
throw new Error(msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const ret = await new Promise(function (resolve, reject) {
|
|
||||||
(async function () {
|
|
||||||
// timeout since NWC is async (user needs to confirm payment in wallet)
|
|
||||||
// timeout is same as invoice expiry
|
|
||||||
const timeout = JIT_INVOICE_TIMEOUT_MS
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
const msg = 'timeout waiting for payment'
|
|
||||||
logger.error(msg)
|
|
||||||
reject(new InvoiceExpiredError(hash))
|
|
||||||
}, timeout)
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
method: 'pay_invoice',
|
|
||||||
params: { invoice: bolt11 }
|
|
||||||
}
|
|
||||||
const content = await nip04.encrypt(secret, walletPubkey, JSON.stringify(payload))
|
|
||||||
|
|
||||||
const request = finalizeEvent({
|
|
||||||
kind: 23194,
|
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
|
||||||
tags: [['p', walletPubkey]],
|
|
||||||
content
|
|
||||||
}, secret)
|
|
||||||
await relay.publish(request)
|
|
||||||
|
|
||||||
const filter = {
|
|
||||||
kinds: [23195],
|
|
||||||
authors: [walletPubkey],
|
|
||||||
'#e': [request.id]
|
|
||||||
}
|
|
||||||
relay.subscribe([filter], {
|
|
||||||
async onevent (response) {
|
|
||||||
clearTimeout(timer)
|
|
||||||
try {
|
|
||||||
const content = JSON.parse(await nip04.decrypt(secret, walletPubkey, response.content))
|
|
||||||
if (content.error) return reject(new Error(content.error.message))
|
|
||||||
if (content.result) return resolve({ preimage: content.result.preimage })
|
|
||||||
} catch (err) {
|
|
||||||
return reject(err)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onclose (reason) {
|
|
||||||
clearTimeout(timer)
|
|
||||||
if (!['closed by caller', 'relay connection closed by us'].includes(reason)) {
|
|
||||||
// only log if not closed by us (caller)
|
|
||||||
const msg = 'connection closed: ' + (reason || 'unknown reason')
|
|
||||||
logger.error(msg)
|
|
||||||
reject(new Error(msg))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})().catch(reject)
|
|
||||||
})
|
|
||||||
const preimage = ret.preimage
|
|
||||||
logger.ok('payment successful:', `payment_hash=${hash}`, `preimage=${preimage}`)
|
|
||||||
return ret
|
|
||||||
} catch (err) {
|
|
||||||
logger.error('payment failed:', `payment_hash=${hash}`, err.message || err.toString?.())
|
|
||||||
throw err
|
|
||||||
} finally {
|
|
||||||
relay?.close()?.catch()
|
|
||||||
if (relay) logger.info(`closed connection to ${relayUrl}`)
|
|
||||||
}
|
|
||||||
}, [walletPubkey, relayUrl, secret, logger])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadConfig().catch(err => logger.error(err.message || err.toString?.()))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const value = useMemo(
|
|
||||||
() => ({ name: 'NWC', nwcUrl, relayUrl, walletPubkey, secret, status, saveConfig, clearConfig, getInfo, sendPayment }),
|
|
||||||
[nwcUrl, relayUrl, walletPubkey, secret, status, saveConfig, clearConfig, getInfo, sendPayment])
|
|
||||||
return (
|
|
||||||
<NWCContext.Provider value={value}>
|
|
||||||
{children}
|
|
||||||
</NWCContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useNWC () {
|
|
||||||
return useContext(NWCContext)
|
|
||||||
}
|
|
|
@ -100,27 +100,6 @@ export const SEND_TO_LNADDR = gql`
|
||||||
}
|
}
|
||||||
}`
|
}`
|
||||||
|
|
||||||
export const UPSERT_WALLET_LNADDR =
|
|
||||||
gql`
|
|
||||||
mutation upsertWalletLNAddr($id: ID, $address: String!, $settings: AutowithdrawSettings!) {
|
|
||||||
upsertWalletLNAddr(id: $id, address: $address, settings: $settings)
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
export const UPSERT_WALLET_LND =
|
|
||||||
gql`
|
|
||||||
mutation upsertWalletLND($id: ID, $socket: String!, $macaroon: String!, $cert: String, $settings: AutowithdrawSettings!) {
|
|
||||||
upsertWalletLND(id: $id, socket: $socket, macaroon: $macaroon, cert: $cert, settings: $settings)
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
export const UPSERT_WALLET_CLN =
|
|
||||||
gql`
|
|
||||||
mutation upsertWalletCLN($id: ID, $socket: String!, $rune: String!, $cert: String, $settings: AutowithdrawSettings!) {
|
|
||||||
upsertWalletCLN(id: $id, socket: $socket, rune: $rune, cert: $cert, settings: $settings)
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
export const REMOVE_WALLET =
|
export const REMOVE_WALLET =
|
||||||
gql`
|
gql`
|
||||||
mutation removeWallet($id: ID!) {
|
mutation removeWallet($id: ID!) {
|
||||||
|
@ -160,6 +139,7 @@ export const WALLET_BY_TYPE = gql`
|
||||||
walletByType(type: $type) {
|
walletByType(type: $type) {
|
||||||
id
|
id
|
||||||
createdAt
|
createdAt
|
||||||
|
enabled
|
||||||
priority
|
priority
|
||||||
type
|
type
|
||||||
wallet {
|
wallet {
|
||||||
|
|
|
@ -140,21 +140,4 @@ export const NORMAL_POLL_INTERVAL = Number(process.env.NEXT_PUBLIC_NORMAL_POLL_I
|
||||||
export const LONG_POLL_INTERVAL = Number(process.env.NEXT_PUBLIC_LONG_POLL_INTERVAL)
|
export const LONG_POLL_INTERVAL = Number(process.env.NEXT_PUBLIC_LONG_POLL_INTERVAL)
|
||||||
export const EXTRA_LONG_POLL_INTERVAL = Number(process.env.NEXT_PUBLIC_EXTRA_LONG_POLL_INTERVAL)
|
export const EXTRA_LONG_POLL_INTERVAL = Number(process.env.NEXT_PUBLIC_EXTRA_LONG_POLL_INTERVAL)
|
||||||
|
|
||||||
// attached wallets
|
|
||||||
export const Wallet = {
|
|
||||||
LND: { logTag: 'lnd', server: true, type: 'LND', field: 'walletLND' },
|
|
||||||
CLN: { logTag: 'cln', server: true, type: 'CLN', field: 'walletCLN' },
|
|
||||||
LnAddr: { logTag: 'lnAddr', server: true, type: 'LIGHTNING_ADDRESS', field: 'walletLightningAddress' },
|
|
||||||
NWC: { logTag: 'nwc', server: false },
|
|
||||||
LNbits: { logTag: 'lnbits', server: false },
|
|
||||||
LNC: { logTag: 'lnc', server: false }
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getWalletBy = (key, value) => {
|
|
||||||
for (const w of Object.values(Wallet)) {
|
|
||||||
if (w[key] === value) return w
|
|
||||||
}
|
|
||||||
throw new Error(`wallet not found: ${key}=${value}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ZAP_UNDO_DELAY_MS = 5_000
|
export const ZAP_UNDO_DELAY_MS = 5_000
|
||||||
|
|
|
@ -22,7 +22,7 @@ function macaroonOPs (macaroon) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('macaroonOPs error:', e)
|
console.error('macaroonOPs error:', e.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
return []
|
return []
|
||||||
|
|
133
lib/validate.js
133
lib/validate.js
|
@ -6,13 +6,10 @@ import {
|
||||||
} from './constants'
|
} from './constants'
|
||||||
import { SUPPORTED_CURRENCIES } from './currency'
|
import { SUPPORTED_CURRENCIES } from './currency'
|
||||||
import { NOSTR_MAX_RELAY_NUM, NOSTR_PUBKEY_BECH32, NOSTR_PUBKEY_HEX } from './nostr'
|
import { NOSTR_MAX_RELAY_NUM, NOSTR_PUBKEY_BECH32, NOSTR_PUBKEY_HEX } from './nostr'
|
||||||
import { msatsToSats, numWithUnits, abbrNum, ensureB64, B64_URL_REGEX } from './format'
|
import { msatsToSats, numWithUnits, abbrNum, ensureB64 } from './format'
|
||||||
import * as usersFragments from '@/fragments/users'
|
import * as usersFragments from '@/fragments/users'
|
||||||
import * as subsFragments from '@/fragments/subs'
|
import * as subsFragments from '@/fragments/subs'
|
||||||
import { isInvoicableMacaroon, isInvoiceMacaroon } from './macaroon'
|
|
||||||
import { TOR_REGEXP, parseNwcUrl } from './url'
|
|
||||||
import { datePivot } from './time'
|
import { datePivot } from './time'
|
||||||
import { decodeRune } from '@/lib/cln'
|
|
||||||
import bip39Words from './bip39-words'
|
import bip39Words from './bip39-words'
|
||||||
|
|
||||||
const { SUB } = subsFragments
|
const { SUB } = subsFragments
|
||||||
|
@ -151,13 +148,13 @@ const nameValidator = string()
|
||||||
const intValidator = number().typeError('must be a number').integer('must be whole')
|
const intValidator = number().typeError('must be a number').integer('must be whole')
|
||||||
const floatValidator = number().typeError('must be a number')
|
const floatValidator = number().typeError('must be a number')
|
||||||
|
|
||||||
const lightningAddressValidator = process.env.NODE_ENV === 'development'
|
export const lightningAddressValidator = process.env.NODE_ENV === 'development'
|
||||||
? string().or(
|
? string().or(
|
||||||
[string().matches(/^[\w_]+@localhost:\d+$/), string().email()],
|
[string().matches(/^[\w_]+@localhost:\d+$/), string().email()],
|
||||||
'address is no good')
|
'address is no good')
|
||||||
: string().email('address is no good')
|
: string().email('address is no good')
|
||||||
|
|
||||||
const hexOrBase64Validator = string().test({
|
export const hexOrBase64Validator = string().test({
|
||||||
name: 'hex-or-base64',
|
name: 'hex-or-base64',
|
||||||
message: 'invalid encoding',
|
message: 'invalid encoding',
|
||||||
test: (val) => {
|
test: (val) => {
|
||||||
|
@ -305,62 +302,10 @@ export function advSchema (args) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function lnAddrAutowithdrawSchema ({ me } = {}) {
|
export const autowithdrawSchemaMembers = {
|
||||||
return object({
|
enabled: boolean(),
|
||||||
address: lightningAddressValidator.required('required').test({
|
autoWithdrawThreshold: intValidator.required('required').min(0, 'must be at least 0').max(msatsToSats(BALANCE_LIMIT_MSATS), `must be at most ${abbrNum(msatsToSats(BALANCE_LIMIT_MSATS))}`),
|
||||||
name: 'address',
|
autoWithdrawMaxFeePercent: floatValidator.required('required').min(0, 'must be at least 0').max(50, 'must not exceed 50')
|
||||||
test: addr => !addr.endsWith('@stacker.news'),
|
|
||||||
message: 'automated withdrawals must be external'
|
|
||||||
}),
|
|
||||||
...autowithdrawSchemaMembers({ me })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LNDAutowithdrawSchema ({ me } = {}) {
|
|
||||||
return object({
|
|
||||||
socket: string().socket().required('required'),
|
|
||||||
macaroon: hexOrBase64Validator.required('required').test({
|
|
||||||
name: 'macaroon',
|
|
||||||
test: v => isInvoiceMacaroon(v) || isInvoicableMacaroon(v),
|
|
||||||
message: 'not an invoice macaroon or an invoicable macaroon'
|
|
||||||
}),
|
|
||||||
cert: hexOrBase64Validator,
|
|
||||||
...autowithdrawSchemaMembers({ me })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CLNAutowithdrawSchema ({ me } = {}) {
|
|
||||||
return object({
|
|
||||||
socket: string().socket().required('required'),
|
|
||||||
rune: string().matches(B64_URL_REGEX, { message: 'invalid rune' }).required('required')
|
|
||||||
.test({
|
|
||||||
name: 'rune',
|
|
||||||
test: (v, context) => {
|
|
||||||
const decoded = decodeRune(v)
|
|
||||||
if (!decoded) return context.createError({ message: 'invalid rune' })
|
|
||||||
if (decoded.restrictions.length === 0) {
|
|
||||||
return context.createError({ message: 'rune must be restricted to method=invoice' })
|
|
||||||
}
|
|
||||||
if (decoded.restrictions.length !== 1 || decoded.restrictions[0].alternatives.length !== 1) {
|
|
||||||
return context.createError({ message: 'rune must be restricted to method=invoice only' })
|
|
||||||
}
|
|
||||||
if (decoded.restrictions[0].alternatives[0] !== 'method=invoice') {
|
|
||||||
return context.createError({ message: 'rune must be restricted to method=invoice only' })
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
cert: hexOrBase64Validator,
|
|
||||||
...autowithdrawSchemaMembers({ me })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function autowithdrawSchemaMembers ({ me } = {}) {
|
|
||||||
return {
|
|
||||||
priority: boolean(),
|
|
||||||
autoWithdrawThreshold: intValidator.required('required').min(0, 'must be at least 0').max(msatsToSats(BALANCE_LIMIT_MSATS), `must be at most ${abbrNum(msatsToSats(BALANCE_LIMIT_MSATS))}`),
|
|
||||||
autoWithdrawMaxFeePercent: floatValidator.required('required').min(0, 'must be at least 0').max(50, 'must not exceed 50')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function bountySchema (args) {
|
export function bountySchema (args) {
|
||||||
|
@ -600,66 +545,24 @@ export const lnAddrSchema = ({ payerData, min, max, commentAllowed } = {}) =>
|
||||||
return accum
|
return accum
|
||||||
}, {})))
|
}, {})))
|
||||||
|
|
||||||
export const lnbitsSchema = object({
|
|
||||||
url: process.env.NODE_ENV === 'development'
|
|
||||||
? string()
|
|
||||||
.or([string().matches(/^(http:\/\/)?localhost:\d+$/), string().url()], 'invalid url')
|
|
||||||
.required('required').trim()
|
|
||||||
: string().url().required('required').trim()
|
|
||||||
.test(async (url, context) => {
|
|
||||||
if (TOR_REGEXP.test(url)) {
|
|
||||||
// allow HTTP and HTTPS over Tor
|
|
||||||
if (!/^https?:\/\//.test(url)) {
|
|
||||||
return context.createError({ message: 'http or https required' })
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
// force HTTPS over clearnet
|
|
||||||
await string().https().validate(url)
|
|
||||||
} catch (err) {
|
|
||||||
return context.createError({ message: err.message })
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}),
|
|
||||||
adminKey: string().length(32)
|
|
||||||
})
|
|
||||||
|
|
||||||
export const nwcSchema = object({
|
|
||||||
nwcUrl: string()
|
|
||||||
.required('required')
|
|
||||||
.test(async (nwcUrl, context) => {
|
|
||||||
// run validation in sequence to control order of errors
|
|
||||||
// inspired by https://github.com/jquense/yup/issues/851#issuecomment-1049705180
|
|
||||||
try {
|
|
||||||
await string().required('required').validate(nwcUrl)
|
|
||||||
await string().matches(/^nostr\+?walletconnect:\/\//, { message: 'must start with nostr+walletconnect://' }).validate(nwcUrl)
|
|
||||||
let relayUrl, walletPubkey, secret
|
|
||||||
try {
|
|
||||||
({ relayUrl, 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')
|
|
||||||
}
|
|
||||||
await string().required('pubkey required').trim().matches(NOSTR_PUBKEY_HEX, 'pubkey must be 64 hex chars').validate(walletPubkey)
|
|
||||||
await string().required('relay url required').trim().wss('relay must use wss://').validate(relayUrl)
|
|
||||||
await string().required('secret required').trim().matches(/^[0-9a-fA-F]{64}$/, 'secret must be 64 hex chars').validate(secret)
|
|
||||||
} catch (err) {
|
|
||||||
return context.createError({ message: err.message })
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
export const lncSchema = object({
|
export const lncSchema = object({
|
||||||
pairingPhrase: array()
|
pairingPhrase: array()
|
||||||
.transform(function (value, originalValue) {
|
.transform(function (value, originalValue) {
|
||||||
if (this.isType(value) && value !== null) {
|
if (this.isType(value) && value !== null) {
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
return originalValue ? originalValue.split(/[\s]+/) : []
|
return originalValue ? originalValue.trim().split(/[\s]+/) : []
|
||||||
|
})
|
||||||
|
.test(async (words, context) => {
|
||||||
|
for (const w of words) {
|
||||||
|
try {
|
||||||
|
await string().oneOf(bip39Words).validate(w)
|
||||||
|
} catch {
|
||||||
|
return context.createError({ message: `'${w}' is not a valid pairing phrase word` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
})
|
})
|
||||||
.of(string().trim().oneOf(bip39Words, ({ value }) => `'${value}' is not a valid pairing phrase word`))
|
|
||||||
.min(2, 'needs at least two words')
|
.min(2, 'needs at least two words')
|
||||||
.max(10, 'max 10 words')
|
.max(10, 'max 10 words')
|
||||||
.required('required'),
|
.required('required'),
|
||||||
|
|
|
@ -0,0 +1,106 @@
|
||||||
|
import { array, object, string } from 'yup'
|
||||||
|
import { autowithdrawSchemaMembers, hexOrBase64Validator, lightningAddressValidator } from '@/lib/validate'
|
||||||
|
import { TOR_REGEXP } from '@/lib/url'
|
||||||
|
import { B64_URL_REGEX } from '@/lib/format'
|
||||||
|
|
||||||
|
export function generateResolverName (walletField) {
|
||||||
|
const capitalized = walletField[0].toUpperCase() + walletField.slice(1)
|
||||||
|
return `upsertWallet${capitalized}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateSchema (wallet) {
|
||||||
|
if (wallet.schema) return wallet.schema
|
||||||
|
|
||||||
|
const fieldValidator = (field) => {
|
||||||
|
if (!field.validate) {
|
||||||
|
// default validation
|
||||||
|
let validator = string()
|
||||||
|
if (!field.optional) validator = validator.required('required')
|
||||||
|
return validator
|
||||||
|
}
|
||||||
|
|
||||||
|
const { type: validationType, words, min, max } = field.validate
|
||||||
|
|
||||||
|
let validator
|
||||||
|
|
||||||
|
if (validationType === 'string') validator = string()
|
||||||
|
|
||||||
|
if (validationType === 'url') {
|
||||||
|
validator = process.env.NODE_ENV === 'development'
|
||||||
|
? string()
|
||||||
|
.or([string().matches(/^(http:\/\/)?localhost:\d+$/), string().url()], 'invalid url')
|
||||||
|
: string()
|
||||||
|
.url()
|
||||||
|
.test(async (url, context) => {
|
||||||
|
if (field.validate.torAllowed && TOR_REGEXP.test(url)) {
|
||||||
|
// allow HTTP and HTTPS over Tor
|
||||||
|
if (!/^https?:\/\//.test(url)) {
|
||||||
|
return context.createError({ message: 'http or https required' })
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// force HTTPS over clearnet
|
||||||
|
await string().https().validate(url)
|
||||||
|
} catch (err) {
|
||||||
|
return context.createError({ message: err.message })
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (words) {
|
||||||
|
validator = array()
|
||||||
|
.transform(function (value, originalValue) {
|
||||||
|
if (this.isType(value) && value !== null) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return originalValue ? originalValue.trim().split(/[\s]+/) : []
|
||||||
|
})
|
||||||
|
.test(async (values, context) => {
|
||||||
|
for (const v of values) {
|
||||||
|
try {
|
||||||
|
await string().oneOf(words).validate(v)
|
||||||
|
} catch {
|
||||||
|
return context.createError({ message: `'${v}' is not a valid ${field.label} word` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validationType === 'email') validator = lightningAddressValidator
|
||||||
|
|
||||||
|
if (validationType === 'socket') validator = string().socket()
|
||||||
|
|
||||||
|
if (validationType === 'hexOrBase64') validator = hexOrBase64Validator
|
||||||
|
|
||||||
|
if (validationType === 'b64url') validator = string().matches(B64_URL_REGEX, { message: `invalid ${field.name}` })
|
||||||
|
|
||||||
|
if (min !== undefined) validator = validator.min(min)
|
||||||
|
if (max !== undefined) validator = validator.max(max)
|
||||||
|
|
||||||
|
if (field.validate.length) validator = validator.length(field.validate.length)
|
||||||
|
|
||||||
|
if (!field.optional) validator = validator.required('required')
|
||||||
|
|
||||||
|
if (field.validate.test) {
|
||||||
|
validator = validator.test({
|
||||||
|
name: field.name,
|
||||||
|
...(typeof field.validate.test === 'function' ? { test: field.validate.test } : field.validate.test)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return validator
|
||||||
|
}
|
||||||
|
|
||||||
|
return object({
|
||||||
|
...wallet.fields.reduce((acc, field) => {
|
||||||
|
return {
|
||||||
|
...acc,
|
||||||
|
[field.name]: fieldValidator(field)
|
||||||
|
}
|
||||||
|
}, {}),
|
||||||
|
...(wallet.walletType ? autowithdrawSchemaMembers : {})
|
||||||
|
})
|
||||||
|
}
|
|
@ -232,7 +232,10 @@ module.exports = withPlausibleProxy()({
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// const ignorePlugin = new webpack.IgnorePlugin({ resourceRegExp: /server\.js$/ })
|
||||||
|
|
||||||
config.plugins.push(workboxPlugin)
|
config.plugins.push(workboxPlugin)
|
||||||
|
// config.plugins.push(ignorePlugin)
|
||||||
}
|
}
|
||||||
|
|
||||||
config.module.rules.push(
|
config.module.rules.push(
|
||||||
|
|
|
@ -17,8 +17,8 @@ import { SSR } from '@/lib/constants'
|
||||||
import NProgress from 'nprogress'
|
import NProgress from 'nprogress'
|
||||||
import 'nprogress/nprogress.css'
|
import 'nprogress/nprogress.css'
|
||||||
import { LoggerProvider } from '@/components/logger'
|
import { LoggerProvider } from '@/components/logger'
|
||||||
|
import { WalletLoggerProvider } from '@/components/wallet-logger'
|
||||||
import { ChainFeeProvider } from '@/components/chain-fee.js'
|
import { ChainFeeProvider } from '@/components/chain-fee.js'
|
||||||
import { WebLNProvider } from '@/components/webln'
|
|
||||||
import dynamic from 'next/dynamic'
|
import dynamic from 'next/dynamic'
|
||||||
import { HasNewNotesProvider } from '@/components/use-has-new-notes'
|
import { HasNewNotesProvider } from '@/components/use-has-new-notes'
|
||||||
|
|
||||||
|
@ -105,11 +105,11 @@ export default function MyApp ({ Component, pageProps: { ...props } }) {
|
||||||
<MeProvider me={me}>
|
<MeProvider me={me}>
|
||||||
<HasNewNotesProvider>
|
<HasNewNotesProvider>
|
||||||
<LoggerProvider>
|
<LoggerProvider>
|
||||||
<ServiceWorkerProvider>
|
<WalletLoggerProvider>
|
||||||
<PriceProvider price={price}>
|
<ServiceWorkerProvider>
|
||||||
<LightningProvider>
|
<PriceProvider price={price}>
|
||||||
<ToastProvider>
|
<LightningProvider>
|
||||||
<WebLNProvider>
|
<ToastProvider>
|
||||||
<ShowModalProvider>
|
<ShowModalProvider>
|
||||||
<BlockHeightProvider blockHeight={blockHeight}>
|
<BlockHeightProvider blockHeight={blockHeight}>
|
||||||
<ChainFeeProvider chainFee={chainFee}>
|
<ChainFeeProvider chainFee={chainFee}>
|
||||||
|
@ -120,11 +120,11 @@ export default function MyApp ({ Component, pageProps: { ...props } }) {
|
||||||
</ChainFeeProvider>
|
</ChainFeeProvider>
|
||||||
</BlockHeightProvider>
|
</BlockHeightProvider>
|
||||||
</ShowModalProvider>
|
</ShowModalProvider>
|
||||||
</WebLNProvider>
|
</ToastProvider>
|
||||||
</ToastProvider>
|
</LightningProvider>
|
||||||
</LightningProvider>
|
</PriceProvider>
|
||||||
</PriceProvider>
|
</ServiceWorkerProvider>
|
||||||
</ServiceWorkerProvider>
|
</WalletLoggerProvider>
|
||||||
</LoggerProvider>
|
</LoggerProvider>
|
||||||
</HasNewNotesProvider>
|
</HasNewNotesProvider>
|
||||||
</MeProvider>
|
</MeProvider>
|
||||||
|
|
|
@ -12,7 +12,7 @@ export default function FullInvoice () {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CenterLayout>
|
<CenterLayout>
|
||||||
<Invoice id={router.query.id} query={INVOICE_FULL} poll description status='loading' bolt11Info webLn={false} />
|
<Invoice id={router.query.id} query={INVOICE_FULL} poll description status='loading' bolt11Info useWallet={false} />
|
||||||
</CenterLayout>
|
</CenterLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,140 @@
|
||||||
|
import { getGetServerSideProps } from '@/api/ssrApollo'
|
||||||
|
import { Form, ClientInput, ClientCheckbox, PasswordInput } from '@/components/form'
|
||||||
|
import { CenterLayout } from '@/components/layout'
|
||||||
|
import { WalletSecurityBanner } from '@/components/banners'
|
||||||
|
import { WalletLogs } from '@/components/wallet-logger'
|
||||||
|
import { useToast } from '@/components/toast'
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
|
import { useWallet, Status } from 'wallets'
|
||||||
|
import Info from '@/components/info'
|
||||||
|
import Text from '@/components/text'
|
||||||
|
import { AutowithdrawSettings } from '@/components/autowithdraw-shared'
|
||||||
|
import dynamic from 'next/dynamic'
|
||||||
|
import { generateSchema } from '@/lib/wallet'
|
||||||
|
|
||||||
|
const WalletButtonBar = dynamic(() => import('@/components/wallet-buttonbar.js'), { ssr: false })
|
||||||
|
|
||||||
|
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 initial = wallet.fields.reduce((acc, field) => {
|
||||||
|
// We still need to run over all wallet fields via reduce
|
||||||
|
// even though we use wallet.config as the initial value
|
||||||
|
// since wallet.config is empty when wallet is not configured.
|
||||||
|
// 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] || ''
|
||||||
|
}
|
||||||
|
}, wallet.config)
|
||||||
|
|
||||||
|
const schema = generateSchema(wallet)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CenterLayout>
|
||||||
|
<h2 className='pb-2'>{wallet.card.title}</h2>
|
||||||
|
<h6 className='text-muted text-center pb-3'><Text>{wallet.card.subtitle}</Text></h6>
|
||||||
|
{!wallet.walletType && <WalletSecurityBanner />}
|
||||||
|
<Form
|
||||||
|
initial={initial}
|
||||||
|
schema={schema}
|
||||||
|
onSubmit={async ({ amount, ...values }) => {
|
||||||
|
try {
|
||||||
|
const newConfig = !wallet.isConfigured
|
||||||
|
|
||||||
|
// enable wallet if wallet was just configured
|
||||||
|
if (newConfig) {
|
||||||
|
values.enabled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
await wallet.save(values)
|
||||||
|
|
||||||
|
if (values.enabled) wallet.enable()
|
||||||
|
else wallet.disable()
|
||||||
|
|
||||||
|
toaster.success('saved settings')
|
||||||
|
router.push('/settings/wallets')
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
const message = 'failed to attach: ' + err.message || err.toString?.()
|
||||||
|
toaster.danger(message)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<WalletFields wallet={wallet} />
|
||||||
|
{wallet.walletType
|
||||||
|
? <AutowithdrawSettings wallet={wallet} />
|
||||||
|
: (
|
||||||
|
<ClientCheckbox
|
||||||
|
disabled={!wallet.isConfigured}
|
||||||
|
initialValue={wallet.status === Status.Enabled}
|
||||||
|
label='enabled'
|
||||||
|
name='enabled'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<WalletButtonBar
|
||||||
|
wallet={wallet} onDelete={async () => {
|
||||||
|
try {
|
||||||
|
await wallet.delete()
|
||||||
|
toaster.success('saved settings')
|
||||||
|
router.push('/settings/wallets')
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
const message = 'failed to detach: ' + err.message || err.toString?.()
|
||||||
|
toaster.danger(message)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form>
|
||||||
|
<div className='mt-3 w-100'>
|
||||||
|
<WalletLogs wallet={wallet} embedded />
|
||||||
|
</div>
|
||||||
|
</CenterLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function WalletFields ({ wallet: { config, fields, isConfigured } }) {
|
||||||
|
return fields
|
||||||
|
.map(({ name, label, type, help, optional, editable, ...props }, i) => {
|
||||||
|
const rawProps = {
|
||||||
|
...props,
|
||||||
|
name,
|
||||||
|
initialValue: config?.[name],
|
||||||
|
readOnly: isConfigured && editable === false,
|
||||||
|
groupClassName: props.hidden ? 'd-none' : undefined,
|
||||||
|
label: label
|
||||||
|
? (
|
||||||
|
<div className='d-flex align-items-center'>
|
||||||
|
{label}
|
||||||
|
{/* help can be a string or object to customize the label */}
|
||||||
|
{help && (
|
||||||
|
<Info label={help.label || 'help'}>
|
||||||
|
<Text>{help.text || help}</Text>
|
||||||
|
</Info>
|
||||||
|
)}
|
||||||
|
{optional && (
|
||||||
|
<small className='text-muted ms-2'>
|
||||||
|
{typeof optional === 'boolean' ? 'optional' : <Text>{optional}</Text>}
|
||||||
|
</small>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: undefined,
|
||||||
|
required: !optional,
|
||||||
|
autoFocus: i === 0
|
||||||
|
}
|
||||||
|
if (type === 'text') {
|
||||||
|
return <ClientInput key={i} {...rawProps} />
|
||||||
|
}
|
||||||
|
if (type === 'password') {
|
||||||
|
return <PasswordInput key={i} {...rawProps} newPass />
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
}
|
|
@ -1,137 +0,0 @@
|
||||||
import { getGetServerSideProps } from '@/api/ssrApollo'
|
|
||||||
import { Form, Input } from '@/components/form'
|
|
||||||
import { CenterLayout } from '@/components/layout'
|
|
||||||
import { useMe } from '@/components/me'
|
|
||||||
import { WalletButtonBar, WalletCard } from '@/components/wallet-card'
|
|
||||||
import { useApolloClient, useMutation } from '@apollo/client'
|
|
||||||
import { useToast } from '@/components/toast'
|
|
||||||
import { CLNAutowithdrawSchema } from '@/lib/validate'
|
|
||||||
import { useRouter } from 'next/router'
|
|
||||||
import { AutowithdrawSettings, autowithdrawInitial } from '@/components/autowithdraw-shared'
|
|
||||||
import { REMOVE_WALLET, UPSERT_WALLET_CLN, WALLET_BY_TYPE } from '@/fragments/wallet'
|
|
||||||
import WalletLogs from '@/components/wallet-logs'
|
|
||||||
import Info from '@/components/info'
|
|
||||||
import Text from '@/components/text'
|
|
||||||
import { Wallet } from '@/lib/constants'
|
|
||||||
|
|
||||||
const variables = { type: Wallet.CLN.type }
|
|
||||||
export const getServerSideProps = getGetServerSideProps({ query: WALLET_BY_TYPE, variables, authRequired: true })
|
|
||||||
|
|
||||||
export default function CLN ({ ssrData }) {
|
|
||||||
const me = useMe()
|
|
||||||
const toaster = useToast()
|
|
||||||
const router = useRouter()
|
|
||||||
const client = useApolloClient()
|
|
||||||
const [upsertWalletCLN] = useMutation(UPSERT_WALLET_CLN, {
|
|
||||||
refetchQueries: ['WalletLogs'],
|
|
||||||
onError: (err) => {
|
|
||||||
client.refetchQueries({ include: ['WalletLogs'] })
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
})
|
|
||||||
const [removeWallet] = useMutation(REMOVE_WALLET, {
|
|
||||||
refetchQueries: ['WalletLogs'],
|
|
||||||
onError: (err) => {
|
|
||||||
client.refetchQueries({ include: ['WalletLogs'] })
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const { walletByType: wallet } = ssrData || {}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CenterLayout>
|
|
||||||
<h2 className='pb-2'>CLN</h2>
|
|
||||||
<h6 className='text-muted text-center'>autowithdraw to your Core Lightning node via <a href='https://docs.corelightning.org/docs/rest' target='_blank' noreferrer rel='noreferrer'>CLNRest</a></h6>
|
|
||||||
<Form
|
|
||||||
initial={{
|
|
||||||
socket: wallet?.wallet?.socket || '',
|
|
||||||
rune: wallet?.wallet?.rune || '',
|
|
||||||
cert: wallet?.wallet?.cert || '',
|
|
||||||
...autowithdrawInitial({ me, priority: wallet?.priority })
|
|
||||||
}}
|
|
||||||
schema={CLNAutowithdrawSchema({ me })}
|
|
||||||
onSubmit={async ({ socket, rune, cert, ...settings }) => {
|
|
||||||
try {
|
|
||||||
await upsertWalletCLN({
|
|
||||||
variables: {
|
|
||||||
id: wallet?.id,
|
|
||||||
socket,
|
|
||||||
rune,
|
|
||||||
cert,
|
|
||||||
settings: {
|
|
||||||
...settings,
|
|
||||||
autoWithdrawThreshold: Number(settings.autoWithdrawThreshold),
|
|
||||||
autoWithdrawMaxFeePercent: Number(settings.autoWithdrawMaxFeePercent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
toaster.success('saved settings')
|
|
||||||
router.push('/settings/wallets')
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
label='rest host and port'
|
|
||||||
name='socket'
|
|
||||||
hint='tor or clearnet'
|
|
||||||
placeholder='55.5.555.55:3010'
|
|
||||||
clear
|
|
||||||
required
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={
|
|
||||||
<div className='d-flex align-items-center'>invoice only rune
|
|
||||||
<Info>
|
|
||||||
<Text>
|
|
||||||
{'We only accept runes that *only* allow `method=invoice`.\nRun this to generate one:\n\n```lightning-cli createrune restrictions=\'["method=invoice"]\'```'}
|
|
||||||
</Text>
|
|
||||||
</Info>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
name='rune'
|
|
||||||
clear
|
|
||||||
hint='must be restricted to method=invoice'
|
|
||||||
placeholder='S34KtUW-6gqS_hD_9cc_PNhfF-NinZyBOCgr1aIrark9NCZtZXRob2Q9aW52b2ljZQ=='
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={<>cert <small className='text-muted ms-2'>optional if from <a href='https://en.wikipedia.org/wiki/Certificate_authority' target='_blank' rel='noreferrer'>CA</a> (e.g. voltage)</small></>}
|
|
||||||
name='cert'
|
|
||||||
clear
|
|
||||||
hint='hex or base64 encoded'
|
|
||||||
placeholder='LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNNVENDQWRpZ0F3SUJBZ0lRSHBFdFdrcGJwZHV4RVF2eVBPc3NWVEFLQmdncWhrak9QUVFEQWpBdk1SOHcKSFFZRFZRUUtFeFpzYm1RZ1lYVjBiMmRsYm1WeVlYUmxaQ0JqWlhKME1Rd3dDZ1lEVlFRREV3TmliMkl3SGhjTgpNalF3TVRBM01qQXhORE0wV2hjTk1qVXdNekF6TWpBeE5ETTBXakF2TVI4d0hRWURWUVFLRXhac2JtUWdZWFYwCmIyZGxibVZ5WVhSbFpDQmpaWEowTVF3d0NnWURWUVFERXdOaWIySXdXVEFUQmdjcWhrak9QUUlCQmdncWhrak8KUFFNQkJ3TkNBQVJUS3NMVk5oZnhqb1FLVDlkVVdDbzUzSmQwTnBuL1BtYi9LUE02M1JxbU52dFYvdFk4NjJJZwpSbE41cmNHRnBEajhUeFc2OVhIK0pTcHpjWDdlN3N0Um80SFZNSUhTTUE0R0ExVWREd0VCL3dRRUF3SUNwREFUCkJnTlZIU1VFRERBS0JnZ3JCZ0VGQlFjREFUQVBCZ05WSFJNQkFmOEVCVEFEQVFIL01CMEdBMVVkRGdRV0JCVDAKMnh3V25GeHRUNzI0MWxwZlNoNm9FWi9UMWpCN0JnTlZIUkVFZERCeWdnTmliMktDQ1d4dlkyRnNhRzl6ZElJRApZbTlpZ2d4d2IyeGhjaTF1TVMxaWIyS0NGR2h2YzNRdVpHOWphMlZ5TG1sdWRHVnlibUZzZ2dSMWJtbDRnZ3AxCmJtbDRjR0ZqYTJWMGdnZGlkV1pqYjI1dWh3Ui9BQUFCaHhBQUFBQUFBQUFBQUFBQUFBQUFBQUFCaHdTc0VnQUQKTUFvR0NDcUdTTTQ5QkFNQ0EwY0FNRVFDSUEwUTlkRXdoNXpPRnpwL3hYeHNpemh5SkxNVG5yazU1VWx1NHJPRwo4WW52QWlBVGt4U3p3Y3hZZnFscGx0UlNIbmd0NUJFcDBzcXlHL05nenBzb2pmMGNqQT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K'
|
|
||||||
/>
|
|
||||||
<AutowithdrawSettings />
|
|
||||||
<WalletButtonBar
|
|
||||||
status={!!wallet} onDelete={async () => {
|
|
||||||
try {
|
|
||||||
await removeWallet({ variables: { id: wallet?.id } })
|
|
||||||
toaster.success('saved settings')
|
|
||||||
router.push('/settings/wallets')
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Form>
|
|
||||||
<div className='mt-3 w-100'>
|
|
||||||
<WalletLogs wallet={Wallet.CLN} embedded />
|
|
||||||
</div>
|
|
||||||
</CenterLayout>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CLNCard ({ wallet }) {
|
|
||||||
return (
|
|
||||||
<WalletCard
|
|
||||||
title='CLN'
|
|
||||||
badges={['receive only', 'non-custodial']}
|
|
||||||
provider='cln'
|
|
||||||
status={wallet !== undefined || undefined}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,29 +1,75 @@
|
||||||
import { getGetServerSideProps } from '@/api/ssrApollo'
|
import { getGetServerSideProps } from '@/api/ssrApollo'
|
||||||
import Layout from '@/components/layout'
|
import Layout from '@/components/layout'
|
||||||
import styles from '@/styles/wallet.module.css'
|
import styles from '@/styles/wallet.module.css'
|
||||||
import { WalletCard } from '@/components/wallet-card'
|
|
||||||
import { LightningAddressWalletCard } from './lightning-address'
|
|
||||||
import { LNbitsCard } from './lnbits'
|
|
||||||
import { NWCCard } from './nwc'
|
|
||||||
import { LNDCard } from './lnd'
|
|
||||||
import { CLNCard } from './cln'
|
|
||||||
import { WALLETS } from '@/fragments/wallet'
|
|
||||||
import { useQuery } from '@apollo/client'
|
|
||||||
import PageLoading from '@/components/page-loading'
|
|
||||||
import { LNCCard } from './lnc'
|
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { Wallet as W } from '@/lib/constants'
|
import { useWallets, walletPrioritySort } from 'wallets'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import dynamic from 'next/dynamic'
|
||||||
|
|
||||||
export const getServerSideProps = getGetServerSideProps({ query: WALLETS, authRequired: true })
|
const WalletCard = dynamic(() => import('@/components/wallet-card'), { ssr: false })
|
||||||
|
|
||||||
|
export const getServerSideProps = getGetServerSideProps({ authRequired: true })
|
||||||
|
|
||||||
|
async function reorder (wallets, sourceIndex, targetIndex) {
|
||||||
|
const newOrder = [...wallets]
|
||||||
|
|
||||||
|
const [source] = newOrder.splice(sourceIndex, 1)
|
||||||
|
const newTargetIndex = sourceIndex < targetIndex ? targetIndex - 1 : targetIndex
|
||||||
|
const append = sourceIndex < targetIndex
|
||||||
|
|
||||||
|
newOrder.splice(newTargetIndex + (append ? 1 : 0), 0, source)
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
newOrder.map((w, i) =>
|
||||||
|
w.setPriority(i).catch(console.error)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function Wallet ({ ssrData }) {
|
export default function Wallet ({ ssrData }) {
|
||||||
const { data } = useQuery(WALLETS)
|
const { wallets } = useWallets()
|
||||||
|
|
||||||
if (!data && !ssrData) return <PageLoading />
|
const [mounted, setMounted] = useState(false)
|
||||||
const { wallets } = data || ssrData
|
const [sourceIndex, setSourceIndex] = useState(null)
|
||||||
const lnd = wallets.find(w => w.type === W.LND.type)
|
const [targetIndex, setTargetIndex] = useState(null)
|
||||||
const lnaddr = wallets.find(w => w.type === W.LnAddr.type)
|
|
||||||
const cln = wallets.find(w => w.type === W.CLN.type)
|
useEffect(() => {
|
||||||
|
// mounted is required since draggable is false
|
||||||
|
// for wallets only available on the client during SSR
|
||||||
|
// and thus we need to render the component again on the client
|
||||||
|
setMounted(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onDragStart = (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)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDragEnter = (i) => (e) => {
|
||||||
|
setTargetIndex(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDragEnd = async (e) => {
|
||||||
|
setSourceIndex(null)
|
||||||
|
setTargetIndex(null)
|
||||||
|
|
||||||
|
if (sourceIndex === targetIndex) return
|
||||||
|
|
||||||
|
await reorder(wallets, sourceIndex, targetIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onTouchStart = (i) => async (e) => {
|
||||||
|
if (sourceIndex !== null) {
|
||||||
|
await reorder(wallets, sourceIndex, i)
|
||||||
|
setSourceIndex(null)
|
||||||
|
} else {
|
||||||
|
setSourceIndex(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
|
@ -35,16 +81,42 @@ export default function Wallet ({ ssrData }) {
|
||||||
wallet logs
|
wallet logs
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.walletGrid}>
|
<div className={styles.walletGrid} onDragEnd={onDragEnd}>
|
||||||
<LightningAddressWalletCard wallet={lnaddr} />
|
{wallets
|
||||||
<LNDCard wallet={lnd} />
|
.sort((w1, w2) => {
|
||||||
<CLNCard wallet={cln} />
|
// enabled/configured wallets always come before disabled/unconfigured wallets
|
||||||
<LNbitsCard />
|
if ((w1.enabled && !w2.enabled) || (w1.isConfigured && !w2.isConfigured)) {
|
||||||
<NWCCard />
|
return -1
|
||||||
<LNCCard />
|
} else if ((w2.enabled && !w1.enabled) || (w2.isConfigured && !w1.isConfigured)) {
|
||||||
<WalletCard title='coming soon' badges={['probably']} />
|
return 1
|
||||||
<WalletCard title='coming soon' badges={['we hope']} />
|
}
|
||||||
<WalletCard title='coming soon' badges={['tm']} />
|
|
||||||
|
return walletPrioritySort(w1, w2)
|
||||||
|
})
|
||||||
|
.map((w, i) => {
|
||||||
|
const draggable = mounted && w.enabled
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={w.name}
|
||||||
|
draggable={draggable}
|
||||||
|
style={{ cursor: draggable ? 'move' : 'default' }}
|
||||||
|
onDragStart={draggable ? onDragStart(i) : undefined}
|
||||||
|
onTouchStart={draggable ? onTouchStart(i) : undefined}
|
||||||
|
onDragEnter={draggable ? onDragEnter(i) : undefined}
|
||||||
|
className={
|
||||||
|
!draggable
|
||||||
|
? ''
|
||||||
|
: (`${sourceIndex === i ? styles.drag : ''} ${draggable && targetIndex === i ? styles.drop : ''}`)
|
||||||
|
}
|
||||||
|
suppressHydrationWarning
|
||||||
|
>
|
||||||
|
<WalletCard wallet={w} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
|
@ -1,106 +0,0 @@
|
||||||
import { getGetServerSideProps } from '@/api/ssrApollo'
|
|
||||||
import { Form, Input } from '@/components/form'
|
|
||||||
import { CenterLayout } from '@/components/layout'
|
|
||||||
import { useMe } from '@/components/me'
|
|
||||||
import { WalletButtonBar, WalletCard } from '@/components/wallet-card'
|
|
||||||
import { useApolloClient, useMutation } from '@apollo/client'
|
|
||||||
import { useToast } from '@/components/toast'
|
|
||||||
import { lnAddrAutowithdrawSchema } from '@/lib/validate'
|
|
||||||
import { useRouter } from 'next/router'
|
|
||||||
import { AutowithdrawSettings, autowithdrawInitial } from '@/components/autowithdraw-shared'
|
|
||||||
import { REMOVE_WALLET, UPSERT_WALLET_LNADDR, WALLET_BY_TYPE } from '@/fragments/wallet'
|
|
||||||
import WalletLogs from '@/components/wallet-logs'
|
|
||||||
import { Wallet } from '@/lib/constants'
|
|
||||||
|
|
||||||
const variables = { type: Wallet.LnAddr.type }
|
|
||||||
export const getServerSideProps = getGetServerSideProps({ query: WALLET_BY_TYPE, variables, authRequired: true })
|
|
||||||
|
|
||||||
export default function LightningAddress ({ ssrData }) {
|
|
||||||
const me = useMe()
|
|
||||||
const toaster = useToast()
|
|
||||||
const router = useRouter()
|
|
||||||
const client = useApolloClient()
|
|
||||||
const [upsertWalletLNAddr] = useMutation(UPSERT_WALLET_LNADDR, {
|
|
||||||
refetchQueries: ['WalletLogs'],
|
|
||||||
onError: (err) => {
|
|
||||||
client.refetchQueries({ include: ['WalletLogs'] })
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
})
|
|
||||||
const [removeWallet] = useMutation(REMOVE_WALLET, {
|
|
||||||
refetchQueries: ['WalletLogs'],
|
|
||||||
onError: (err) => {
|
|
||||||
client.refetchQueries({ include: ['WalletLogs'] })
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const { walletByType: wallet } = ssrData || {}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CenterLayout>
|
|
||||||
<h2 className='pb-2'>lightning address</h2>
|
|
||||||
<h6 className='text-muted text-center pb-3'>autowithdraw to a lightning address</h6>
|
|
||||||
<Form
|
|
||||||
initial={{
|
|
||||||
address: wallet?.wallet?.address || '',
|
|
||||||
...autowithdrawInitial({ me, priority: wallet?.priority })
|
|
||||||
}}
|
|
||||||
schema={lnAddrAutowithdrawSchema({ me })}
|
|
||||||
onSubmit={async ({ address, ...settings }) => {
|
|
||||||
try {
|
|
||||||
await upsertWalletLNAddr({
|
|
||||||
variables: {
|
|
||||||
id: wallet?.id,
|
|
||||||
address,
|
|
||||||
settings: {
|
|
||||||
...settings,
|
|
||||||
autoWithdrawThreshold: Number(settings.autoWithdrawThreshold),
|
|
||||||
autoWithdrawMaxFeePercent: Number(settings.autoWithdrawMaxFeePercent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
toaster.success('saved settings')
|
|
||||||
router.push('/settings/wallets')
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
label='lightning address'
|
|
||||||
name='address'
|
|
||||||
autoComplete='off'
|
|
||||||
required
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<AutowithdrawSettings />
|
|
||||||
<WalletButtonBar
|
|
||||||
status={!!wallet} onDelete={async () => {
|
|
||||||
try {
|
|
||||||
await removeWallet({ variables: { id: wallet?.id } })
|
|
||||||
toaster.success('saved settings')
|
|
||||||
router.push('/settings/wallets')
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Form>
|
|
||||||
<div className='mt-3 w-100'>
|
|
||||||
<WalletLogs wallet={Wallet.LnAddr} embedded />
|
|
||||||
</div>
|
|
||||||
</CenterLayout>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LightningAddressWalletCard ({ wallet }) {
|
|
||||||
return (
|
|
||||||
<WalletCard
|
|
||||||
title='lightning address'
|
|
||||||
badges={['receive only', 'non-custodialish']}
|
|
||||||
provider='lightning-address'
|
|
||||||
status={wallet !== undefined || undefined}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,99 +0,0 @@
|
||||||
import { getGetServerSideProps } from '@/api/ssrApollo'
|
|
||||||
import { Form, ClientInput, ClientCheckbox, PasswordInput } from '@/components/form'
|
|
||||||
import { CenterLayout } from '@/components/layout'
|
|
||||||
import { WalletButtonBar, WalletCard, isConfigured } from '@/components/wallet-card'
|
|
||||||
import { lnbitsSchema } from '@/lib/validate'
|
|
||||||
import { useToast } from '@/components/toast'
|
|
||||||
import { useRouter } from 'next/router'
|
|
||||||
import { useLNbits } from '@/components/webln/lnbits'
|
|
||||||
import { WalletSecurityBanner } from '@/components/banners'
|
|
||||||
import { useWebLNConfigurator } from '@/components/webln'
|
|
||||||
import WalletLogs from '@/components/wallet-logs'
|
|
||||||
import { Wallet } from '@/lib/constants'
|
|
||||||
|
|
||||||
export const getServerSideProps = getGetServerSideProps({ authRequired: true })
|
|
||||||
|
|
||||||
export default function LNbits () {
|
|
||||||
const { provider, enabledProviders, setProvider } = useWebLNConfigurator()
|
|
||||||
const lnbits = useLNbits()
|
|
||||||
const { name, url, adminKey, saveConfig, clearConfig, status } = lnbits
|
|
||||||
const isDefault = provider?.name === name
|
|
||||||
const configured = isConfigured(status)
|
|
||||||
const toaster = useToast()
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CenterLayout>
|
|
||||||
<h2 className='pb-2'>LNbits</h2>
|
|
||||||
<h6 className='text-muted text-center pb-3'>use LNbits for payments</h6>
|
|
||||||
<WalletSecurityBanner />
|
|
||||||
<Form
|
|
||||||
initial={{
|
|
||||||
url: url || '',
|
|
||||||
adminKey: adminKey || '',
|
|
||||||
isDefault: isDefault || false
|
|
||||||
}}
|
|
||||||
schema={lnbitsSchema}
|
|
||||||
onSubmit={async ({ isDefault, ...values }) => {
|
|
||||||
try {
|
|
||||||
await saveConfig(values)
|
|
||||||
if (isDefault) setProvider(lnbits)
|
|
||||||
toaster.success('saved settings')
|
|
||||||
router.push('/settings/wallets')
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
toaster.danger('failed to attach: ' + err.message || err.toString?.())
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ClientInput
|
|
||||||
initialValue={url}
|
|
||||||
label='lnbits url'
|
|
||||||
name='url'
|
|
||||||
required
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<PasswordInput
|
|
||||||
initialValue={adminKey}
|
|
||||||
label='admin key'
|
|
||||||
name='adminKey'
|
|
||||||
newPass
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<ClientCheckbox
|
|
||||||
disabled={!configured || isDefault || enabledProviders.length === 1}
|
|
||||||
initialValue={isDefault}
|
|
||||||
label='default payment method'
|
|
||||||
name='isDefault'
|
|
||||||
/>
|
|
||||||
<WalletButtonBar
|
|
||||||
status={status} onDelete={async () => {
|
|
||||||
try {
|
|
||||||
await clearConfig()
|
|
||||||
toaster.success('saved settings')
|
|
||||||
router.push('/settings/wallets')
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
toaster.danger('failed to detach: ' + err.message || err.toString?.())
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Form>
|
|
||||||
<div className='mt-3 w-100'>
|
|
||||||
<WalletLogs wallet={Wallet.LNbits} embedded />
|
|
||||||
</div>
|
|
||||||
</CenterLayout>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LNbitsCard () {
|
|
||||||
const { status } = useLNbits()
|
|
||||||
return (
|
|
||||||
<WalletCard
|
|
||||||
title='LNbits'
|
|
||||||
badges={['send only', 'non-custodialish']}
|
|
||||||
provider='lnbits'
|
|
||||||
status={status}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,122 +0,0 @@
|
||||||
import { getGetServerSideProps } from '@/api/ssrApollo'
|
|
||||||
import { WalletSecurityBanner } from '@/components/banners'
|
|
||||||
import { ClientCheckbox, Form, PasswordInput } from '@/components/form'
|
|
||||||
import Info from '@/components/info'
|
|
||||||
import { CenterLayout } from '@/components/layout'
|
|
||||||
import Text from '@/components/text'
|
|
||||||
import { useToast } from '@/components/toast'
|
|
||||||
import { WalletButtonBar, WalletCard, isConfigured } from '@/components/wallet-card'
|
|
||||||
import WalletLogs from '@/components/wallet-logs'
|
|
||||||
import { Status, useWebLNConfigurator } from '@/components/webln'
|
|
||||||
import { XXX_DEFAULT_PASSWORD, useLNC } from '@/components/webln/lnc'
|
|
||||||
import { lncSchema } from '@/lib/validate'
|
|
||||||
import { useRouter } from 'next/router'
|
|
||||||
import { useEffect, useRef } from 'react'
|
|
||||||
import { Wallet } from '@/lib/constants'
|
|
||||||
|
|
||||||
export const getServerSideProps = getGetServerSideProps({ authRequired: true })
|
|
||||||
|
|
||||||
export default function LNC () {
|
|
||||||
const { provider, enabledProviders, setProvider } = useWebLNConfigurator()
|
|
||||||
const toaster = useToast()
|
|
||||||
const router = useRouter()
|
|
||||||
const lnc = useLNC()
|
|
||||||
const { status, clearConfig, saveConfig, config, name, unlock } = lnc
|
|
||||||
const isDefault = provider?.name === name
|
|
||||||
const unlocking = useRef(false)
|
|
||||||
const configured = isConfigured(status)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!unlocking.current && status === Status.Locked) {
|
|
||||||
unlocking.current = true
|
|
||||||
unlock()
|
|
||||||
}
|
|
||||||
}, [status, unlock])
|
|
||||||
|
|
||||||
const defaultPassword = config?.password === XXX_DEFAULT_PASSWORD
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CenterLayout>
|
|
||||||
<h2>Lightning Node Connect for LND</h2>
|
|
||||||
<h6 className='text-muted text-center pb-3'>use Lightning Node Connect for LND payments</h6>
|
|
||||||
<WalletSecurityBanner />
|
|
||||||
<Form
|
|
||||||
initial={{
|
|
||||||
pairingPhrase: config?.pairingPhrase || '',
|
|
||||||
password: (!config?.password || defaultPassword) ? '' : config.password
|
|
||||||
}}
|
|
||||||
schema={lncSchema}
|
|
||||||
onSubmit={async ({ isDefault, ...values }) => {
|
|
||||||
try {
|
|
||||||
await saveConfig(values)
|
|
||||||
if (isDefault) setProvider(lnc)
|
|
||||||
toaster.success('saved settings')
|
|
||||||
router.push('/settings/wallets')
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
toaster.danger('failed to attach: ' + err.message || err.toString?.())
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PasswordInput
|
|
||||||
label={
|
|
||||||
<div className='d-flex align-items-center'>pairing phrase
|
|
||||||
<Info label='help'>
|
|
||||||
<Text>
|
|
||||||
{'We only need permissions for the uri `/lnrpc.Lightning/SendPaymentSync`\n\nCreate a budgeted account with narrow permissions:\n\n```$ litcli accounts create --balance <budget>```\n\n```$ litcli sessions add --type custom --label <your label> --account_id <account_id> --uri /lnrpc.Lightning/SendPaymentSync```\n\nGrab the `pairing_secret_mnemonic` from the output and paste it here.'}
|
|
||||||
</Text>
|
|
||||||
</Info>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
name='pairingPhrase'
|
|
||||||
initialValue={config?.pairingPhrase}
|
|
||||||
newPass={config?.pairingPhrase === undefined}
|
|
||||||
readOnly={configured}
|
|
||||||
required
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<PasswordInput
|
|
||||||
label={<>password <small className='text-muted ms-2'>optional</small></>}
|
|
||||||
name='password'
|
|
||||||
initialValue={defaultPassword ? '' : config?.password}
|
|
||||||
newPass={config?.password === undefined || defaultPassword}
|
|
||||||
readOnly={configured}
|
|
||||||
hint='encrypts your pairing phrase when stored locally'
|
|
||||||
/>
|
|
||||||
<ClientCheckbox
|
|
||||||
disabled={!configured || isDefault || enabledProviders?.length === 1}
|
|
||||||
initialValue={isDefault}
|
|
||||||
label='default payment method'
|
|
||||||
name='isDefault'
|
|
||||||
/>
|
|
||||||
<WalletButtonBar
|
|
||||||
status={status} onDelete={async () => {
|
|
||||||
try {
|
|
||||||
await clearConfig()
|
|
||||||
toaster.success('saved settings')
|
|
||||||
router.push('/settings/wallets')
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
toaster.danger('failed to detach: ' + err.message || err.toString?.())
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Form>
|
|
||||||
<div className='mt-3 w-100'>
|
|
||||||
<WalletLogs wallet={Wallet.LNC} embedded />
|
|
||||||
</div>
|
|
||||||
</CenterLayout>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LNCCard () {
|
|
||||||
const { status } = useLNC()
|
|
||||||
return (
|
|
||||||
<WalletCard
|
|
||||||
title='LNC'
|
|
||||||
badges={['send only', 'non-custodial', 'budgetable']}
|
|
||||||
provider='lnc'
|
|
||||||
status={status}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,137 +0,0 @@
|
||||||
import { getGetServerSideProps } from '@/api/ssrApollo'
|
|
||||||
import { Form, Input } from '@/components/form'
|
|
||||||
import { CenterLayout } from '@/components/layout'
|
|
||||||
import { useMe } from '@/components/me'
|
|
||||||
import { WalletButtonBar, WalletCard } from '@/components/wallet-card'
|
|
||||||
import { useApolloClient, useMutation } from '@apollo/client'
|
|
||||||
import { useToast } from '@/components/toast'
|
|
||||||
import { LNDAutowithdrawSchema } from '@/lib/validate'
|
|
||||||
import { useRouter } from 'next/router'
|
|
||||||
import { AutowithdrawSettings, autowithdrawInitial } from '@/components/autowithdraw-shared'
|
|
||||||
import { REMOVE_WALLET, UPSERT_WALLET_LND, WALLET_BY_TYPE } from '@/fragments/wallet'
|
|
||||||
import Info from '@/components/info'
|
|
||||||
import Text from '@/components/text'
|
|
||||||
import WalletLogs from '@/components/wallet-logs'
|
|
||||||
import { Wallet } from '@/lib/constants'
|
|
||||||
|
|
||||||
const variables = { type: Wallet.LND.type }
|
|
||||||
export const getServerSideProps = getGetServerSideProps({ query: WALLET_BY_TYPE, variables, authRequired: true })
|
|
||||||
|
|
||||||
export default function LND ({ ssrData }) {
|
|
||||||
const me = useMe()
|
|
||||||
const toaster = useToast()
|
|
||||||
const router = useRouter()
|
|
||||||
const client = useApolloClient()
|
|
||||||
const [upsertWalletLND] = useMutation(UPSERT_WALLET_LND, {
|
|
||||||
refetchQueries: ['WalletLogs'],
|
|
||||||
onError: (err) => {
|
|
||||||
client.refetchQueries({ include: ['WalletLogs'] })
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
})
|
|
||||||
const [removeWallet] = useMutation(REMOVE_WALLET, {
|
|
||||||
refetchQueries: ['WalletLogs'],
|
|
||||||
onError: (err) => {
|
|
||||||
client.refetchQueries({ include: ['WalletLogs'] })
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const { walletByType: wallet } = ssrData || {}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CenterLayout>
|
|
||||||
<h2 className='pb-2'>LND</h2>
|
|
||||||
<h6 className='text-muted text-center pb-3'>autowithdraw to your Lightning Labs node</h6>
|
|
||||||
<Form
|
|
||||||
initial={{
|
|
||||||
socket: wallet?.wallet?.socket || '',
|
|
||||||
macaroon: wallet?.wallet?.macaroon || '',
|
|
||||||
cert: wallet?.wallet?.cert || '',
|
|
||||||
...autowithdrawInitial({ me, priority: wallet?.priority })
|
|
||||||
}}
|
|
||||||
schema={LNDAutowithdrawSchema({ me })}
|
|
||||||
onSubmit={async ({ socket, cert, macaroon, ...settings }) => {
|
|
||||||
try {
|
|
||||||
await upsertWalletLND({
|
|
||||||
variables: {
|
|
||||||
id: wallet?.id,
|
|
||||||
socket,
|
|
||||||
macaroon,
|
|
||||||
cert,
|
|
||||||
settings: {
|
|
||||||
...settings,
|
|
||||||
autoWithdrawThreshold: Number(settings.autoWithdrawThreshold),
|
|
||||||
autoWithdrawMaxFeePercent: Number(settings.autoWithdrawMaxFeePercent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
toaster.success('saved settings')
|
|
||||||
router.push('/settings/wallets')
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
label='grpc host and port'
|
|
||||||
name='socket'
|
|
||||||
hint='tor or clearnet'
|
|
||||||
placeholder='55.5.555.55:10001'
|
|
||||||
clear
|
|
||||||
required
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={
|
|
||||||
<div className='d-flex align-items-center'>invoice macaroon
|
|
||||||
<Info label='privacy tip'>
|
|
||||||
<Text>
|
|
||||||
{'We accept a prebaked ***invoice.macaroon*** for your convenience. To gain better privacy, generate a new macaroon as follows:\n\n```lncli bakemacaroon invoices:write invoices:read```'}
|
|
||||||
</Text>
|
|
||||||
</Info>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
name='macaroon'
|
|
||||||
clear
|
|
||||||
hint='hex or base64 encoded'
|
|
||||||
placeholder='AgEDbG5kAlgDChCn7YgfWX7uTkQQgXZ2uahNEgEwGhYKB2FkZHJlc3MSBHJlYWQSBXdyaXRlGhcKCGludm9pY2VzEgRyZWFkEgV3cml0ZRoPCgdvbmNoYWluEgRyZWFkAAAGIJkMBrrDV0npU90JV0TGNJPrqUD8m2QYoTDjolaL6eBs'
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={<>cert <small className='text-muted ms-2'>optional if from <a href='https://en.wikipedia.org/wiki/Certificate_authority' target='_blank' rel='noreferrer'>CA</a> (e.g. voltage)</small></>}
|
|
||||||
name='cert'
|
|
||||||
clear
|
|
||||||
hint='hex or base64 encoded'
|
|
||||||
placeholder='LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNNVENDQWRpZ0F3SUJBZ0lRSHBFdFdrcGJwZHV4RVF2eVBPc3NWVEFLQmdncWhrak9QUVFEQWpBdk1SOHcKSFFZRFZRUUtFeFpzYm1RZ1lYVjBiMmRsYm1WeVlYUmxaQ0JqWlhKME1Rd3dDZ1lEVlFRREV3TmliMkl3SGhjTgpNalF3TVRBM01qQXhORE0wV2hjTk1qVXdNekF6TWpBeE5ETTBXakF2TVI4d0hRWURWUVFLRXhac2JtUWdZWFYwCmIyZGxibVZ5WVhSbFpDQmpaWEowTVF3d0NnWURWUVFERXdOaWIySXdXVEFUQmdjcWhrak9QUUlCQmdncWhrak8KUFFNQkJ3TkNBQVJUS3NMVk5oZnhqb1FLVDlkVVdDbzUzSmQwTnBuL1BtYi9LUE02M1JxbU52dFYvdFk4NjJJZwpSbE41cmNHRnBEajhUeFc2OVhIK0pTcHpjWDdlN3N0Um80SFZNSUhTTUE0R0ExVWREd0VCL3dRRUF3SUNwREFUCkJnTlZIU1VFRERBS0JnZ3JCZ0VGQlFjREFUQVBCZ05WSFJNQkFmOEVCVEFEQVFIL01CMEdBMVVkRGdRV0JCVDAKMnh3V25GeHRUNzI0MWxwZlNoNm9FWi9UMWpCN0JnTlZIUkVFZERCeWdnTmliMktDQ1d4dlkyRnNhRzl6ZElJRApZbTlpZ2d4d2IyeGhjaTF1TVMxaWIyS0NGR2h2YzNRdVpHOWphMlZ5TG1sdWRHVnlibUZzZ2dSMWJtbDRnZ3AxCmJtbDRjR0ZqYTJWMGdnZGlkV1pqYjI1dWh3Ui9BQUFCaHhBQUFBQUFBQUFBQUFBQUFBQUFBQUFCaHdTc0VnQUQKTUFvR0NDcUdTTTQ5QkFNQ0EwY0FNRVFDSUEwUTlkRXdoNXpPRnpwL3hYeHNpemh5SkxNVG5yazU1VWx1NHJPRwo4WW52QWlBVGt4U3p3Y3hZZnFscGx0UlNIbmd0NUJFcDBzcXlHL05nenBzb2pmMGNqQT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K'
|
|
||||||
/>
|
|
||||||
<AutowithdrawSettings />
|
|
||||||
<WalletButtonBar
|
|
||||||
status={!!wallet} onDelete={async () => {
|
|
||||||
try {
|
|
||||||
await removeWallet({ variables: { id: wallet?.id } })
|
|
||||||
toaster.success('saved settings')
|
|
||||||
router.push('/settings/wallets')
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Form>
|
|
||||||
<div className='mt-3 w-100'>
|
|
||||||
<WalletLogs wallet={Wallet.LND} embedded />
|
|
||||||
</div>
|
|
||||||
</CenterLayout>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LNDCard ({ wallet }) {
|
|
||||||
return (
|
|
||||||
<WalletCard
|
|
||||||
title='LND'
|
|
||||||
badges={['receive only', 'non-custodial']}
|
|
||||||
provider='lnd'
|
|
||||||
status={wallet !== undefined || undefined}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,92 +0,0 @@
|
||||||
import { getGetServerSideProps } from '@/api/ssrApollo'
|
|
||||||
import { Form, ClientCheckbox, PasswordInput } from '@/components/form'
|
|
||||||
import { CenterLayout } from '@/components/layout'
|
|
||||||
import { WalletButtonBar, WalletCard, isConfigured } from '@/components/wallet-card'
|
|
||||||
import { nwcSchema } from '@/lib/validate'
|
|
||||||
import { useToast } from '@/components/toast'
|
|
||||||
import { useRouter } from 'next/router'
|
|
||||||
import { useNWC } from '@/components/webln/nwc'
|
|
||||||
import { WalletSecurityBanner } from '@/components/banners'
|
|
||||||
import { useWebLNConfigurator } from '@/components/webln'
|
|
||||||
import WalletLogs from '@/components/wallet-logs'
|
|
||||||
import { Wallet } from '@/lib/constants'
|
|
||||||
|
|
||||||
export const getServerSideProps = getGetServerSideProps({ authRequired: true })
|
|
||||||
|
|
||||||
export default function NWC () {
|
|
||||||
const { provider, enabledProviders, setProvider } = useWebLNConfigurator()
|
|
||||||
const nwc = useNWC()
|
|
||||||
const { name, nwcUrl, saveConfig, clearConfig, status } = nwc
|
|
||||||
const isDefault = provider?.name === name
|
|
||||||
const configured = isConfigured(status)
|
|
||||||
const toaster = useToast()
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CenterLayout>
|
|
||||||
<h2 className='pb-2'>Nostr Wallet Connect</h2>
|
|
||||||
<h6 className='text-muted text-center pb-3'>use Nostr Wallet Connect for payments</h6>
|
|
||||||
<WalletSecurityBanner />
|
|
||||||
<Form
|
|
||||||
initial={{
|
|
||||||
nwcUrl: nwcUrl || '',
|
|
||||||
isDefault: isDefault || false
|
|
||||||
}}
|
|
||||||
schema={nwcSchema}
|
|
||||||
onSubmit={async ({ isDefault, ...values }) => {
|
|
||||||
try {
|
|
||||||
await saveConfig(values)
|
|
||||||
if (isDefault) setProvider(nwc)
|
|
||||||
toaster.success('saved settings')
|
|
||||||
router.push('/settings/wallets')
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
toaster.danger('failed to attach: ' + err.message || err.toString?.())
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PasswordInput
|
|
||||||
initialValue={nwcUrl}
|
|
||||||
label='connection'
|
|
||||||
name='nwcUrl'
|
|
||||||
newPass
|
|
||||||
required
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<ClientCheckbox
|
|
||||||
disabled={!configured || isDefault || enabledProviders.length === 1}
|
|
||||||
initialValue={isDefault}
|
|
||||||
label='default payment method'
|
|
||||||
name='isDefault'
|
|
||||||
/>
|
|
||||||
<WalletButtonBar
|
|
||||||
status={status} onDelete={async () => {
|
|
||||||
try {
|
|
||||||
await clearConfig()
|
|
||||||
toaster.success('saved settings')
|
|
||||||
router.push('/settings/wallets')
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
toaster.danger('failed to detach: ' + err.message || err.toString?.())
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Form>
|
|
||||||
<div className='mt-3 w-100'>
|
|
||||||
<WalletLogs wallet={Wallet.NWC} embedded />
|
|
||||||
</div>
|
|
||||||
</CenterLayout>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NWCCard () {
|
|
||||||
const { status } = useNWC()
|
|
||||||
return (
|
|
||||||
<WalletCard
|
|
||||||
title='NWC'
|
|
||||||
badges={['send only', 'non-custodialish', 'budgetable']}
|
|
||||||
provider='nwc'
|
|
||||||
status={status}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { CenterLayout } from '@/components/layout'
|
import { CenterLayout } from '@/components/layout'
|
||||||
import { getGetServerSideProps } from '@/api/ssrApollo'
|
import { getGetServerSideProps } from '@/api/ssrApollo'
|
||||||
import WalletLogs from '@/components/wallet-logs'
|
import { WalletLogs } from '@/components/wallet-logger'
|
||||||
|
|
||||||
export const getServerSideProps = getGetServerSideProps({ query: null })
|
export const getServerSideProps = getGetServerSideProps({ query: null })
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Wallet" ADD COLUMN "enabled" BOOLEAN NOT NULL DEFAULT true;
|
|
@ -173,6 +173,7 @@ model Wallet {
|
||||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||||
userId Int
|
userId Int
|
||||||
label String?
|
label String?
|
||||||
|
enabled Boolean @default(true)
|
||||||
priority Int @default(0)
|
priority Int @default(0)
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
|
|
@ -7,6 +7,14 @@
|
||||||
margin-top: 3rem;
|
margin-top: 3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.drag {
|
||||||
|
opacity: 33%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop {
|
||||||
|
box-shadow: 0 0 10px var(--bs-info);
|
||||||
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
width: 160px;
|
width: 160px;
|
||||||
height: 180px;
|
height: 180px;
|
||||||
|
|
|
@ -0,0 +1,8 @@
|
||||||
|
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'
|
||||||
|
|
||||||
|
export default [nwc, lnbits, lnc, lnAddr, cln, lnd]
|
|
@ -0,0 +1 @@
|
||||||
|
export * from 'wallets/cln'
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { decodeRune } from '@/lib/cln'
|
||||||
|
|
||||||
|
export const name = 'cln'
|
||||||
|
|
||||||
|
export const fields = [
|
||||||
|
{
|
||||||
|
name: 'socket',
|
||||||
|
label: 'rest host and port',
|
||||||
|
type: 'text',
|
||||||
|
placeholder: '55.5.555.55:3010',
|
||||||
|
hint: 'tor or clearnet',
|
||||||
|
clear: true,
|
||||||
|
validate: {
|
||||||
|
type: 'socket'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rune',
|
||||||
|
label: 'invoice only rune',
|
||||||
|
help: {
|
||||||
|
text: 'We only accept runes that *only* allow `method=invoice`.\nRun this to generate one:\n\n```lightning-cli createrune restrictions=\'["method=invoice"]\'```'
|
||||||
|
},
|
||||||
|
type: 'text',
|
||||||
|
placeholder: 'S34KtUW-6gqS_hD_9cc_PNhfF-NinZyBOCgr1aIrark9NCZtZXRob2Q9aW52b2ljZQ==',
|
||||||
|
hint: 'must be restricted to method=invoice',
|
||||||
|
clear: true,
|
||||||
|
validate: {
|
||||||
|
type: 'b64url',
|
||||||
|
test: (v, context) => {
|
||||||
|
const decoded = decodeRune(v)
|
||||||
|
if (!decoded) return context.createError({ message: 'invalid rune' })
|
||||||
|
if (decoded.restrictions.length === 0) {
|
||||||
|
return context.createError({ message: 'rune must be restricted to method=invoice' })
|
||||||
|
}
|
||||||
|
if (decoded.restrictions.length !== 1 || decoded.restrictions[0].alternatives.length !== 1) {
|
||||||
|
return context.createError({ message: 'rune must be restricted to method=invoice only' })
|
||||||
|
}
|
||||||
|
if (decoded.restrictions[0].alternatives[0] !== 'method=invoice') {
|
||||||
|
return context.createError({ message: 'rune must be restricted to method=invoice only' })
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'cert',
|
||||||
|
label: 'cert',
|
||||||
|
type: 'text',
|
||||||
|
placeholder: 'LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNNVENDQWRpZ0F3SUJBZ0lRSHBFdFdrcGJwZHV4RVF2eVBPc3NWVEFLQmdncWhrak9QUVFEQWpBdk1SOHcKSFFZRFZRUUtFeFpzYm1RZ1lYVjBiMmRsYm1WeVlYUmxaQ0JqWlhKME1Rd3dDZ1lEVlFRREV3TmliMkl3SGhjTgpNalF3TVRBM01qQXhORE0wV2hjTk1qVXdNekF6TWpBeE5ETTBXakF2TVI4d0hRWURWUVFLRXhac2JtUWdZWFYwCmIyZGxibVZ5WVhSbFpDQmpaWEowTVF3d0NnWURWUVFERXdOaWIySXdXVEFUQmdjcWhrak9QUUlCQmdncWhrak8KUFFNQkJ3TkNBQVJUS3NMVk5oZnhqb1FLVDlkVVdDbzUzSmQwTnBuL1BtYi9LUE02M1JxbU52dFYvdFk4NjJJZwpSbE41cmNHRnBEajhUeFc2OVhIK0pTcHpjWDdlN3N0Um80SFZNSUhTTUE0R0ExVWREd0VCL3dRRUF3SUNwREFUCkJnTlZIU1VFRERBS0JnZ3JCZ0VGQlFjREFUQVBCZ05WSFJNQkFmOEVCVEFEQVFIL01CMEdBMVVkRGdRV0JCVDAKMnh3V25GeHRUNzI0MWxwZlNoNm9FWi9UMWpCN0JnTlZIUkVFZERCeWdnTmliMktDQ1d4dlkyRnNhRzl6ZElJRApZbTlpZ2d4d2IyeGhjaTF1TVMxaWIyS0NGR2h2YzNRdVpHOWphMlZ5TG1sdWRHVnlibUZzZ2dSMWJtbDRnZ3AxCmJtbDRjR0ZqYTJWMGdnZGlkV1pqYjI1dWh3Ui9BQUFCaHhBQUFBQUFBQUFBQUFBQUFBQUFBQUFCaHdTc0VnQUQKTUFvR0NDcUdTTTQ5QkFNQ0EwY0FNRVFDSUEwUTlkRXdoNXpPRnpwL3hYeHNpemh5SkxNVG5yazU1VWx1NHJPRwo4WW52QWlBVGt4U3p3Y3hZZnFscGx0UlNIbmd0NUJFcDBzcXlHL05nenBzb2pmMGNqQT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K',
|
||||||
|
optional: 'optional if from [CA](https://en.wikipedia.org/wiki/Certificate_authority) (e.g. voltage)',
|
||||||
|
hint: 'hex or base64 encoded',
|
||||||
|
clear: true,
|
||||||
|
validate: {
|
||||||
|
type: 'hexOrBase64'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export const card = {
|
||||||
|
title: 'CLN',
|
||||||
|
subtitle: 'autowithdraw to your Core Lightning node via [CLNRest](https://docs.corelightning.org/docs/rest)',
|
||||||
|
badges: ['receive only', 'non-custodialish']
|
||||||
|
}
|
||||||
|
|
||||||
|
export const walletType = 'CLN'
|
||||||
|
|
||||||
|
export const walletField = 'walletCLN'
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { ensureB64 } from '@/lib/format'
|
||||||
|
import { createInvoice as clnCreateInvoice } from '@/lib/cln'
|
||||||
|
import { addWalletLog } from '@/api/resolvers/wallet'
|
||||||
|
|
||||||
|
export * from 'wallets/cln'
|
||||||
|
|
||||||
|
export const testConnect = async (
|
||||||
|
{ socket, rune, cert },
|
||||||
|
{ me, models }
|
||||||
|
) => {
|
||||||
|
cert = ensureB64(cert)
|
||||||
|
const inv = await clnCreateInvoice({
|
||||||
|
socket,
|
||||||
|
rune,
|
||||||
|
cert,
|
||||||
|
description: 'SN connection test',
|
||||||
|
msats: 'any',
|
||||||
|
expiry: 0
|
||||||
|
})
|
||||||
|
await addWalletLog({ wallet: { type: 'CLN' }, level: 'SUCCESS', message: 'connected to CLN' }, { me, models })
|
||||||
|
return inv
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createInvoice = async (
|
||||||
|
{ amount },
|
||||||
|
{ socket, rune, cert },
|
||||||
|
{ me, models, lnd }
|
||||||
|
) => {
|
||||||
|
cert = ensureB64(cert)
|
||||||
|
|
||||||
|
const inv = await clnCreateInvoice({
|
||||||
|
socket,
|
||||||
|
rune,
|
||||||
|
cert,
|
||||||
|
description: me.hideInvoiceDesc ? undefined : 'autowithdraw to CLN from SN',
|
||||||
|
msats: amount + 'sat',
|
||||||
|
expiry: 360
|
||||||
|
})
|
||||||
|
return inv.bolt11
|
||||||
|
}
|
|
@ -0,0 +1,321 @@
|
||||||
|
import { useCallback } from 'react'
|
||||||
|
import { useMe } from '@/components/me'
|
||||||
|
import useLocalConfig from '@/components/use-local-state'
|
||||||
|
import { useWalletLogger } from '@/components/wallet-logger'
|
||||||
|
import { SSR } from '@/lib/constants'
|
||||||
|
import { bolt11Tags } from '@/lib/bolt11'
|
||||||
|
|
||||||
|
import walletDefs from 'wallets/client'
|
||||||
|
import { gql, useApolloClient, useQuery } from '@apollo/client'
|
||||||
|
import { REMOVE_WALLET, WALLET_BY_TYPE } from '@/fragments/wallet'
|
||||||
|
import { autowithdrawInitial } from '@/components/autowithdraw-shared'
|
||||||
|
import { useShowModal } from '@/components/modal'
|
||||||
|
import { useToast } from '../components/toast'
|
||||||
|
import { generateResolverName } from '@/lib/wallet'
|
||||||
|
|
||||||
|
export const Status = {
|
||||||
|
Initialized: 'Initialized',
|
||||||
|
Enabled: 'Enabled',
|
||||||
|
Locked: 'Locked',
|
||||||
|
Error: 'Error'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWallet (name) {
|
||||||
|
const me = useMe()
|
||||||
|
const showModal = useShowModal()
|
||||||
|
const toaster = useToast()
|
||||||
|
|
||||||
|
const wallet = name ? getWalletByName(name) : getEnabledWallet(me)
|
||||||
|
const { logger, deleteLogs } = useWalletLogger(wallet)
|
||||||
|
|
||||||
|
const [config, saveConfig, clearConfig] = useConfig(wallet)
|
||||||
|
const _isConfigured = isConfigured({ ...wallet, config })
|
||||||
|
|
||||||
|
const status = config?.enabled ? Status.Enabled : Status.Initialized
|
||||||
|
const enabled = status === Status.Enabled
|
||||||
|
const priority = config?.priority
|
||||||
|
|
||||||
|
const sendPayment = useCallback(async (bolt11) => {
|
||||||
|
const hash = bolt11Tags(bolt11).payment_hash
|
||||||
|
logger.info('sending payment:', `payment_hash=${hash}`)
|
||||||
|
try {
|
||||||
|
const { preimage } = await wallet.sendPayment(bolt11, config, { me, logger, status, showModal })
|
||||||
|
logger.ok('payment successful:', `payment_hash=${hash}`, `preimage=${preimage}`)
|
||||||
|
} catch (err) {
|
||||||
|
const message = err.message || err.toString?.()
|
||||||
|
logger.error('payment failed:', `payment_hash=${hash}`, message)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}, [me, wallet, config, logger, status])
|
||||||
|
|
||||||
|
const enable = useCallback(() => {
|
||||||
|
enableWallet(name, me)
|
||||||
|
logger.ok('wallet enabled')
|
||||||
|
}, [name, me, logger])
|
||||||
|
|
||||||
|
const disable = useCallback(() => {
|
||||||
|
disableWallet(name, me)
|
||||||
|
logger.info('wallet disabled')
|
||||||
|
}, [name, me, logger])
|
||||||
|
|
||||||
|
const setPriority = useCallback(async (priority) => {
|
||||||
|
if (_isConfigured && priority !== config.priority) {
|
||||||
|
try {
|
||||||
|
await saveConfig({ ...config, priority })
|
||||||
|
} catch (err) {
|
||||||
|
toaster.danger(`failed to change priority of ${wallet.name} wallet: ${err.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [wallet, config, logger, toaster])
|
||||||
|
|
||||||
|
const save = useCallback(async (newConfig) => {
|
||||||
|
try {
|
||||||
|
// validate should log custom INFO and OK message
|
||||||
|
// validate is optional since validation might happen during save on server
|
||||||
|
// TODO: add timeout
|
||||||
|
const validConfig = await wallet.validate?.(newConfig, { me, logger })
|
||||||
|
await saveConfig(validConfig ?? newConfig)
|
||||||
|
logger.ok(_isConfigured ? 'wallet updated' : 'wallet attached')
|
||||||
|
} catch (err) {
|
||||||
|
const message = err.message || err.toString?.()
|
||||||
|
logger.error('failed to attach: ' + message)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}, [_isConfigured, saveConfig, me, logger])
|
||||||
|
|
||||||
|
// delete is a reserved keyword
|
||||||
|
const delete_ = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await clearConfig()
|
||||||
|
logger.ok('wallet detached')
|
||||||
|
disable()
|
||||||
|
} catch (err) {
|
||||||
|
const message = err.message || err.toString?.()
|
||||||
|
logger.error(message)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}, [clearConfig, logger, disable])
|
||||||
|
|
||||||
|
if (!wallet) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
...wallet,
|
||||||
|
sendPayment,
|
||||||
|
config,
|
||||||
|
save,
|
||||||
|
delete: delete_,
|
||||||
|
deleteLogs,
|
||||||
|
enable,
|
||||||
|
disable,
|
||||||
|
setPriority,
|
||||||
|
isConfigured: _isConfigured,
|
||||||
|
status,
|
||||||
|
enabled,
|
||||||
|
priority,
|
||||||
|
logger
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function useConfig (wallet) {
|
||||||
|
const me = useMe()
|
||||||
|
|
||||||
|
const storageKey = getStorageKey(wallet?.name, me)
|
||||||
|
const [localConfig, setLocalConfig, clearLocalConfig] = useLocalConfig(storageKey)
|
||||||
|
|
||||||
|
const [serverConfig, setServerConfig, clearServerConfig] = useServerConfig(wallet)
|
||||||
|
|
||||||
|
const hasLocalConfig = !!wallet?.sendPayment
|
||||||
|
const hasServerConfig = !!wallet?.walletType
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
// only include config if it makes sense for this wallet
|
||||||
|
// since server config always returns default values for autowithdraw settings
|
||||||
|
// which might be confusing to have for wallets that don't support autowithdraw
|
||||||
|
...(hasLocalConfig ? localConfig : {}),
|
||||||
|
...(hasServerConfig ? serverConfig : {})
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveConfig = useCallback(async (config) => {
|
||||||
|
if (hasLocalConfig) setLocalConfig(config)
|
||||||
|
if (hasServerConfig) await setServerConfig(config)
|
||||||
|
}, [wallet])
|
||||||
|
|
||||||
|
const clearConfig = useCallback(async () => {
|
||||||
|
if (hasLocalConfig) clearLocalConfig()
|
||||||
|
if (hasServerConfig) await clearServerConfig()
|
||||||
|
}, [wallet])
|
||||||
|
|
||||||
|
return [config, saveConfig, clearConfig]
|
||||||
|
}
|
||||||
|
|
||||||
|
function isConfigured ({ fields, config }) {
|
||||||
|
if (!config || !fields) return false
|
||||||
|
|
||||||
|
// a wallet is configured if all of its required fields are set
|
||||||
|
const val = fields.every(field => {
|
||||||
|
return field.optional ? true : !!config?.[field.name]
|
||||||
|
})
|
||||||
|
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
|
function useServerConfig (wallet) {
|
||||||
|
const client = useApolloClient()
|
||||||
|
const me = useMe()
|
||||||
|
|
||||||
|
const { data, refetch: refetchConfig } = useQuery(WALLET_BY_TYPE, { variables: { type: wallet?.walletType }, skip: !wallet?.walletType })
|
||||||
|
|
||||||
|
const walletId = data?.walletByType?.id
|
||||||
|
const serverConfig = {
|
||||||
|
id: walletId,
|
||||||
|
priority: data?.walletByType?.priority,
|
||||||
|
enabled: data?.walletByType?.enabled,
|
||||||
|
...data?.walletByType?.wallet
|
||||||
|
}
|
||||||
|
const autowithdrawSettings = autowithdrawInitial({ me })
|
||||||
|
const config = { ...serverConfig, ...autowithdrawSettings }
|
||||||
|
|
||||||
|
const saveConfig = useCallback(async ({
|
||||||
|
autoWithdrawThreshold,
|
||||||
|
autoWithdrawMaxFeePercent,
|
||||||
|
priority,
|
||||||
|
enabled,
|
||||||
|
...config
|
||||||
|
}) => {
|
||||||
|
try {
|
||||||
|
const mutation = generateMutation(wallet)
|
||||||
|
return await client.mutate({
|
||||||
|
mutation,
|
||||||
|
variables: {
|
||||||
|
id: walletId,
|
||||||
|
...config,
|
||||||
|
settings: {
|
||||||
|
autoWithdrawThreshold: Number(autoWithdrawThreshold),
|
||||||
|
autoWithdrawMaxFeePercent: Number(autoWithdrawMaxFeePercent),
|
||||||
|
priority,
|
||||||
|
enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
client.refetchQueries({ include: ['WalletLogs'] })
|
||||||
|
refetchConfig()
|
||||||
|
}
|
||||||
|
}, [client, walletId])
|
||||||
|
|
||||||
|
const clearConfig = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await client.mutate({
|
||||||
|
mutation: REMOVE_WALLET,
|
||||||
|
variables: { id: walletId }
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
client.refetchQueries({ include: ['WalletLogs'] })
|
||||||
|
refetchConfig()
|
||||||
|
}
|
||||||
|
}, [client, walletId])
|
||||||
|
|
||||||
|
return [config, saveConfig, clearConfig]
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateMutation (wallet) {
|
||||||
|
const resolverName = generateResolverName(wallet.walletField)
|
||||||
|
|
||||||
|
let headerArgs = '$id: ID, '
|
||||||
|
headerArgs += wallet.fields.map(f => {
|
||||||
|
let arg = `$${f.name}: String`
|
||||||
|
if (!f.optional) {
|
||||||
|
arg += '!'
|
||||||
|
}
|
||||||
|
return arg
|
||||||
|
}).join(', ')
|
||||||
|
headerArgs += ', $settings: AutowithdrawSettings!'
|
||||||
|
|
||||||
|
let inputArgs = 'id: $id, '
|
||||||
|
inputArgs += wallet.fields.map(f => `${f.name}: $${f.name}`).join(', ')
|
||||||
|
inputArgs += ', settings: $settings'
|
||||||
|
|
||||||
|
return gql`mutation ${resolverName}(${headerArgs}) {
|
||||||
|
${resolverName}(${inputArgs})
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWalletByName (name) {
|
||||||
|
return walletDefs.find(def => def.name === name)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWalletByType (type) {
|
||||||
|
return walletDefs.find(def => def.walletType === type)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEnabledWallet (me) {
|
||||||
|
return walletDefs
|
||||||
|
.filter(def => !!def.sendPayment)
|
||||||
|
.map(def => {
|
||||||
|
// populate definition with properties from useWallet that are required for sorting
|
||||||
|
const key = getStorageKey(def.name, me)
|
||||||
|
const config = SSR ? null : JSON.parse(window?.localStorage.getItem(key))
|
||||||
|
const priority = config?.priority
|
||||||
|
return { ...def, config, priority }
|
||||||
|
})
|
||||||
|
.filter(({ config }) => config?.enabled)
|
||||||
|
.sort(walletPrioritySort)[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function walletPrioritySort (w1, w2) {
|
||||||
|
const delta = w1.priority - w2.priority
|
||||||
|
// delta is NaN if either priority is undefined
|
||||||
|
if (!Number.isNaN(delta) && delta !== 0) return delta
|
||||||
|
|
||||||
|
// if one wallet has a priority but the other one doesn't, the one with the priority comes first
|
||||||
|
if (w1.priority !== undefined && w2.priority === undefined) return -1
|
||||||
|
if (w1.priority === undefined && w2.priority !== undefined) return 1
|
||||||
|
|
||||||
|
// both wallets have no priority set, falling back to other methods
|
||||||
|
|
||||||
|
// if both wallets have an id, use that as tie breaker
|
||||||
|
// since that's the order in which autowithdrawals are attempted
|
||||||
|
if (w1.config?.id && w2.config?.id) return Number(w1.config.id) - Number(w2.config.id)
|
||||||
|
|
||||||
|
// else we will use the card title as tie breaker
|
||||||
|
return w1.card.title < w2.card.title ? -1 : 1
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWallets () {
|
||||||
|
const wallets = walletDefs.map(def => useWallet(def.name))
|
||||||
|
|
||||||
|
const resetClient = useCallback(async (wallet) => {
|
||||||
|
for (const w of wallets) {
|
||||||
|
if (w.sendPayment) {
|
||||||
|
await w.delete()
|
||||||
|
}
|
||||||
|
await w.deleteLogs()
|
||||||
|
}
|
||||||
|
}, [wallets])
|
||||||
|
|
||||||
|
return { wallets, resetClient }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStorageKey (name, me) {
|
||||||
|
let storageKey = `wallet:${name}`
|
||||||
|
if (me) {
|
||||||
|
storageKey = `${storageKey}:${me.id}`
|
||||||
|
}
|
||||||
|
return storageKey
|
||||||
|
}
|
||||||
|
|
||||||
|
function enableWallet (name, me) {
|
||||||
|
const key = getStorageKey(name, me)
|
||||||
|
const config = JSON.parse(window.localStorage.getItem(key))
|
||||||
|
if (!config) return
|
||||||
|
config.enabled = true
|
||||||
|
window.localStorage.setItem(key, JSON.stringify(config))
|
||||||
|
}
|
||||||
|
|
||||||
|
function disableWallet (name, me) {
|
||||||
|
const key = getStorageKey(name, me)
|
||||||
|
const config = JSON.parse(window.localStorage.getItem(key))
|
||||||
|
if (!config) return
|
||||||
|
config.enabled = false
|
||||||
|
window.localStorage.setItem(key, JSON.stringify(config))
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
export * from 'wallets/lightning-address'
|
|
@ -0,0 +1,28 @@
|
||||||
|
export const name = 'lightning-address'
|
||||||
|
export const shortName = 'lnAddr'
|
||||||
|
|
||||||
|
export const fields = [
|
||||||
|
{
|
||||||
|
name: 'address',
|
||||||
|
label: 'lightning address',
|
||||||
|
type: 'text',
|
||||||
|
autoComplete: 'off',
|
||||||
|
validate: {
|
||||||
|
type: 'email',
|
||||||
|
test: {
|
||||||
|
test: addr => !addr.endsWith('@stacker.news'),
|
||||||
|
message: 'automated withdrawals must be external'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export const card = {
|
||||||
|
title: 'lightning address',
|
||||||
|
subtitle: 'autowithdraw to a lightning address',
|
||||||
|
badges: ['receive only', 'non-custodialish']
|
||||||
|
}
|
||||||
|
|
||||||
|
export const walletType = 'LIGHTNING_ADDRESS'
|
||||||
|
|
||||||
|
export const walletField = 'walletLightningAddress'
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { fetchLnAddrInvoice } from '@/api/resolvers/wallet'
|
||||||
|
import { lnAddrOptions } from '@/lib/lnurl'
|
||||||
|
|
||||||
|
export * from 'wallets/lightning-address'
|
||||||
|
|
||||||
|
export const testConnect = async (
|
||||||
|
{ address },
|
||||||
|
{ me, models, addWalletLog }
|
||||||
|
) => {
|
||||||
|
const options = await lnAddrOptions(address)
|
||||||
|
await addWalletLog({ wallet: { type: 'LIGHTNING_ADDRESS' }, level: 'SUCCESS', message: 'fetched payment details' }, { me, models })
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createInvoice = async (
|
||||||
|
{ amount, maxFee },
|
||||||
|
{ address },
|
||||||
|
{ me, models, lnd, lnService }
|
||||||
|
) => {
|
||||||
|
const res = await fetchLnAddrInvoice({ addr: address, amount, maxFee }, {
|
||||||
|
me,
|
||||||
|
models,
|
||||||
|
lnd,
|
||||||
|
lnService,
|
||||||
|
autoWithdraw: true
|
||||||
|
})
|
||||||
|
return res.pr
|
||||||
|
}
|
|
@ -0,0 +1,80 @@
|
||||||
|
export * from 'wallets/lnbits'
|
||||||
|
|
||||||
|
export async function validate ({ url, adminKey }, { logger }) {
|
||||||
|
logger.info('trying to fetch wallet')
|
||||||
|
|
||||||
|
url = url.replace(/\/+$/, '')
|
||||||
|
await getWallet({ url, adminKey })
|
||||||
|
|
||||||
|
logger.ok('wallet found')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendPayment (bolt11, { url, adminKey }) {
|
||||||
|
url = url.replace(/\/+$/, '')
|
||||||
|
|
||||||
|
const response = await postPayment(bolt11, { url, adminKey })
|
||||||
|
|
||||||
|
const checkResponse = await getPayment(response.payment_hash, { url, adminKey })
|
||||||
|
if (!checkResponse.preimage) {
|
||||||
|
throw new Error('No preimage')
|
||||||
|
}
|
||||||
|
|
||||||
|
const preimage = checkResponse.preimage
|
||||||
|
return { preimage }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getWallet ({ url, adminKey }) {
|
||||||
|
const path = '/api/v1/wallet'
|
||||||
|
|
||||||
|
const headers = new Headers()
|
||||||
|
headers.append('Accept', 'application/json')
|
||||||
|
headers.append('Content-Type', 'application/json')
|
||||||
|
headers.append('X-Api-Key', adminKey)
|
||||||
|
|
||||||
|
const res = await fetch(url + path, { method: 'GET', headers })
|
||||||
|
if (!res.ok) {
|
||||||
|
const errBody = await res.json()
|
||||||
|
throw new Error(errBody.detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
const wallet = await res.json()
|
||||||
|
return wallet
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postPayment (bolt11, { url, adminKey }) {
|
||||||
|
const path = '/api/v1/payments'
|
||||||
|
|
||||||
|
const headers = new Headers()
|
||||||
|
headers.append('Accept', 'application/json')
|
||||||
|
headers.append('Content-Type', 'application/json')
|
||||||
|
headers.append('X-Api-Key', adminKey)
|
||||||
|
|
||||||
|
const body = JSON.stringify({ bolt11, out: true })
|
||||||
|
|
||||||
|
const res = await fetch(url + path, { method: 'POST', headers, body })
|
||||||
|
if (!res.ok) {
|
||||||
|
const errBody = await res.json()
|
||||||
|
throw new Error(errBody.detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
const payment = await res.json()
|
||||||
|
return payment
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPayment (paymentHash, { url, adminKey }) {
|
||||||
|
const path = `/api/v1/payments/${paymentHash}`
|
||||||
|
|
||||||
|
const headers = new Headers()
|
||||||
|
headers.append('Accept', 'application/json')
|
||||||
|
headers.append('Content-Type', 'application/json')
|
||||||
|
headers.append('X-Api-Key', adminKey)
|
||||||
|
|
||||||
|
const res = await fetch(url + path, { method: 'GET', headers })
|
||||||
|
if (!res.ok) {
|
||||||
|
const errBody = await res.json()
|
||||||
|
throw new Error(errBody.detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
const payment = await res.json()
|
||||||
|
return payment
|
||||||
|
}
|
|
@ -0,0 +1,28 @@
|
||||||
|
export const name = 'lnbits'
|
||||||
|
|
||||||
|
export const fields = [
|
||||||
|
{
|
||||||
|
name: 'url',
|
||||||
|
label: 'lnbits url',
|
||||||
|
type: 'text',
|
||||||
|
validate: {
|
||||||
|
type: 'url',
|
||||||
|
torAllowed: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'adminKey',
|
||||||
|
label: 'admin key',
|
||||||
|
type: 'password',
|
||||||
|
validate: {
|
||||||
|
type: 'string',
|
||||||
|
length: 32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export const card = {
|
||||||
|
title: 'LNbits',
|
||||||
|
subtitle: 'use [LNbits](https://lnbits.com/) for payments',
|
||||||
|
badges: ['send only', 'non-custodialish']
|
||||||
|
}
|
|
@ -0,0 +1,161 @@
|
||||||
|
import { InvoiceCanceledError, InvoiceExpiredError } from '@/components/payment'
|
||||||
|
import { bolt11Tags } from '@/lib/bolt11'
|
||||||
|
import { Mutex } from 'async-mutex'
|
||||||
|
export * from 'wallets/lnc'
|
||||||
|
|
||||||
|
async function disconnect (lnc, logger) {
|
||||||
|
if (lnc) {
|
||||||
|
try {
|
||||||
|
lnc.disconnect()
|
||||||
|
logger.info('disconnecting...')
|
||||||
|
// wait for lnc to disconnect before releasing the mutex
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
let counter = 0
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (lnc?.isConnected) {
|
||||||
|
if (counter++ > 100) {
|
||||||
|
logger.error('failed to disconnect from lnc')
|
||||||
|
clearInterval(interval)
|
||||||
|
reject(new Error('failed to disconnect from lnc'))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clearInterval(interval)
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
}, 50)
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('failed to disconnect from lnc', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validate (credentials, { me, logger }) {
|
||||||
|
let lnc
|
||||||
|
try {
|
||||||
|
lnc = await getLNC(credentials)
|
||||||
|
|
||||||
|
logger.info('connecting ...')
|
||||||
|
await lnc.connect()
|
||||||
|
logger.ok('connected')
|
||||||
|
|
||||||
|
logger.info('validating permissions ...')
|
||||||
|
await validateNarrowPerms(lnc)
|
||||||
|
logger.ok('permissions ok')
|
||||||
|
|
||||||
|
return lnc.credentials.credentials
|
||||||
|
} finally {
|
||||||
|
await disconnect(lnc, logger)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mutex = new Mutex()
|
||||||
|
|
||||||
|
export async function sendPayment (bolt11, credentials, { me, status, logger }) {
|
||||||
|
const hash = bolt11Tags(bolt11).payment_hash
|
||||||
|
|
||||||
|
return await mutex.runExclusive(async () => {
|
||||||
|
let lnc
|
||||||
|
try {
|
||||||
|
lnc = await getLNC(credentials)
|
||||||
|
|
||||||
|
await lnc.connect()
|
||||||
|
const { paymentError, paymentPreimage: preimage } =
|
||||||
|
await lnc.lnd.lightning.sendPaymentSync({ payment_request: bolt11 })
|
||||||
|
|
||||||
|
if (paymentError) throw new Error(paymentError)
|
||||||
|
if (!preimage) throw new Error('No preimage in response')
|
||||||
|
|
||||||
|
return { preimage }
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err.message || err.toString?.()
|
||||||
|
if (msg.includes('invoice expired')) {
|
||||||
|
throw new InvoiceExpiredError(hash)
|
||||||
|
}
|
||||||
|
if (msg.includes('canceled')) {
|
||||||
|
throw new InvoiceCanceledError(hash)
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
await disconnect(lnc, logger)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getLNC (credentials = {}) {
|
||||||
|
const { default: { default: LNC } } = await import('@lightninglabs/lnc-web')
|
||||||
|
return new LNC({
|
||||||
|
credentialStore: new LncCredentialStore({ ...credentials, serverHost: 'mailbox.terminal.lightning.today:443' })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateNarrowPerms (lnc) {
|
||||||
|
if (!lnc.hasPerms('lnrpc.Lightning.SendPaymentSync')) {
|
||||||
|
throw new Error('missing permission: lnrpc.Lightning.SendPaymentSync')
|
||||||
|
}
|
||||||
|
if (lnc.hasPerms('lnrpc.Lightning.SendCoins')) {
|
||||||
|
throw new Error('too broad permission: lnrpc.Wallet.SendCoins')
|
||||||
|
}
|
||||||
|
// TODO: need to check for more narrow permissions
|
||||||
|
// blocked by https://github.com/lightninglabs/lnc-web/issues/112
|
||||||
|
}
|
||||||
|
|
||||||
|
// default credential store can go fuck itself
|
||||||
|
class LncCredentialStore {
|
||||||
|
credentials = {
|
||||||
|
localKey: '',
|
||||||
|
remoteKey: '',
|
||||||
|
pairingPhrase: '',
|
||||||
|
serverHost: ''
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor (credentials = {}) {
|
||||||
|
this.credentials = { ...this.credentials, ...credentials }
|
||||||
|
}
|
||||||
|
|
||||||
|
get password () {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
set password (password) { }
|
||||||
|
|
||||||
|
get serverHost () {
|
||||||
|
return this.credentials.serverHost
|
||||||
|
}
|
||||||
|
|
||||||
|
set serverHost (host) {
|
||||||
|
this.credentials.serverHost = host
|
||||||
|
}
|
||||||
|
|
||||||
|
get pairingPhrase () {
|
||||||
|
return this.credentials.pairingPhrase
|
||||||
|
}
|
||||||
|
|
||||||
|
set pairingPhrase (phrase) {
|
||||||
|
this.credentials.pairingPhrase = phrase
|
||||||
|
}
|
||||||
|
|
||||||
|
get localKey () {
|
||||||
|
return this.credentials.localKey
|
||||||
|
}
|
||||||
|
|
||||||
|
set localKey (key) {
|
||||||
|
this.credentials.localKey = key
|
||||||
|
}
|
||||||
|
|
||||||
|
get remoteKey () {
|
||||||
|
return this.credentials.remoteKey
|
||||||
|
}
|
||||||
|
|
||||||
|
set remoteKey (key) {
|
||||||
|
this.credentials.remoteKey = key
|
||||||
|
}
|
||||||
|
|
||||||
|
get isPaired () {
|
||||||
|
return !!this.credentials.remoteKey || !!this.credentials.pairingPhrase
|
||||||
|
}
|
||||||
|
|
||||||
|
clear () {
|
||||||
|
this.credentials = {}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,42 @@
|
||||||
|
import bip39Words from '@/lib/bip39-words'
|
||||||
|
|
||||||
|
export const name = 'lnc'
|
||||||
|
|
||||||
|
export const fields = [
|
||||||
|
{
|
||||||
|
name: 'pairingPhrase',
|
||||||
|
label: 'pairing phrase',
|
||||||
|
type: 'password',
|
||||||
|
help: 'We only need permissions for the uri `/lnrpc.Lightning/SendPaymentSync`\n\nCreate a budgeted account with narrow permissions:\n\n```$ litcli accounts create --balance <budget>```\n\n```$ litcli sessions add --type custom --label <your label> --account_id <account_id> --uri /lnrpc.Lightning/SendPaymentSync```\n\nGrab the `pairing_secret_mnemonic` from the output and paste it here.',
|
||||||
|
validate: {
|
||||||
|
words: bip39Words,
|
||||||
|
min: 2,
|
||||||
|
max: 10
|
||||||
|
},
|
||||||
|
editable: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'localKey',
|
||||||
|
type: 'text',
|
||||||
|
optional: true,
|
||||||
|
hidden: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'remoteKey',
|
||||||
|
type: 'text',
|
||||||
|
optional: true,
|
||||||
|
hidden: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'serverHost',
|
||||||
|
type: 'text',
|
||||||
|
optional: true,
|
||||||
|
hidden: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export const card = {
|
||||||
|
title: 'LNC',
|
||||||
|
subtitle: 'use Lightning Node Connect for LND payments',
|
||||||
|
badges: ['send only', 'non-custodial', 'budgetable']
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
export * from 'wallets/lnd'
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { isInvoicableMacaroon, isInvoiceMacaroon } from '@/lib/macaroon'
|
||||||
|
|
||||||
|
export const name = 'lnd'
|
||||||
|
|
||||||
|
export const fields = [
|
||||||
|
{
|
||||||
|
name: 'socket',
|
||||||
|
label: 'grpc host and port',
|
||||||
|
type: 'text',
|
||||||
|
placeholder: '55.5.555.55:10001',
|
||||||
|
hint: 'tor or clearnet',
|
||||||
|
clear: true,
|
||||||
|
validate: {
|
||||||
|
type: 'socket'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'macaroon',
|
||||||
|
label: 'invoice macaroon',
|
||||||
|
help: {
|
||||||
|
label: 'privacy tip',
|
||||||
|
text: 'We accept a prebaked ***invoice.macaroon*** for your convenience. To gain better privacy, generate a new macaroon as follows:\n\n```lncli bakemacaroon invoices:write invoices:read```'
|
||||||
|
},
|
||||||
|
type: 'text',
|
||||||
|
placeholder: 'AgEDbG5kAlgDChCn7YgfWX7uTkQQgXZ2uahNEgEwGhYKB2FkZHJlc3MSBHJlYWQSBXdyaXRlGhcKCGludm9pY2VzEgRyZWFkEgV3cml0ZRoPCgdvbmNoYWluEgRyZWFkAAAGIJkMBrrDV0npU90JV0TGNJPrqUD8m2QYoTDjolaL6eBs',
|
||||||
|
hint: 'hex or base64 encoded',
|
||||||
|
clear: true,
|
||||||
|
validate: {
|
||||||
|
type: 'hexOrBase64',
|
||||||
|
test: {
|
||||||
|
test: v => isInvoiceMacaroon(v) || isInvoicableMacaroon(v),
|
||||||
|
message: 'not an invoice macaroon or an invoicable macaroon'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'cert',
|
||||||
|
label: 'cert',
|
||||||
|
type: 'text',
|
||||||
|
placeholder: 'LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNNVENDQWRpZ0F3SUJBZ0lRSHBFdFdrcGJwZHV4RVF2eVBPc3NWVEFLQmdncWhrak9QUVFEQWpBdk1SOHcKSFFZRFZRUUtFeFpzYm1RZ1lYVjBiMmRsYm1WeVlYUmxaQ0JqWlhKME1Rd3dDZ1lEVlFRREV3TmliMkl3SGhjTgpNalF3TVRBM01qQXhORE0wV2hjTk1qVXdNekF6TWpBeE5ETTBXakF2TVI4d0hRWURWUVFLRXhac2JtUWdZWFYwCmIyZGxibVZ5WVhSbFpDQmpaWEowTVF3d0NnWURWUVFERXdOaWIySXdXVEFUQmdjcWhrak9QUUlCQmdncWhrak8KUFFNQkJ3TkNBQVJUS3NMVk5oZnhqb1FLVDlkVVdDbzUzSmQwTnBuL1BtYi9LUE02M1JxbU52dFYvdFk4NjJJZwpSbE41cmNHRnBEajhUeFc2OVhIK0pTcHpjWDdlN3N0Um80SFZNSUhTTUE0R0ExVWREd0VCL3dRRUF3SUNwREFUCkJnTlZIU1VFRERBS0JnZ3JCZ0VGQlFjREFUQVBCZ05WSFJNQkFmOEVCVEFEQVFIL01CMEdBMVVkRGdRV0JCVDAKMnh3V25GeHRUNzI0MWxwZlNoNm9FWi9UMWpCN0JnTlZIUkVFZERCeWdnTmliMktDQ1d4dlkyRnNhRzl6ZElJRApZbTlpZ2d4d2IyeGhjaTF1TVMxaWIyS0NGR2h2YzNRdVpHOWphMlZ5TG1sdWRHVnlibUZzZ2dSMWJtbDRnZ3AxCmJtbDRjR0ZqYTJWMGdnZGlkV1pqYjI1dWh3Ui9BQUFCaHhBQUFBQUFBQUFBQUFBQUFBQUFBQUFCaHdTc0VnQUQKTUFvR0NDcUdTTTQ5QkFNQ0EwY0FNRVFDSUEwUTlkRXdoNXpPRnpwL3hYeHNpemh5SkxNVG5yazU1VWx1NHJPRwo4WW52QWlBVGt4U3p3Y3hZZnFscGx0UlNIbmd0NUJFcDBzcXlHL05nenBzb2pmMGNqQT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K',
|
||||||
|
optional: 'optional if from [CA](https://en.wikipedia.org/wiki/Certificate_authority) (e.g. voltage)',
|
||||||
|
hint: 'hex or base64 encoded',
|
||||||
|
clear: true,
|
||||||
|
validate: {
|
||||||
|
type: 'hexOrBase64'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export const card = {
|
||||||
|
title: 'LND',
|
||||||
|
subtitle: 'autowithdraw to your Lightning Labs node',
|
||||||
|
badges: ['receive only', 'non-custodial']
|
||||||
|
}
|
||||||
|
|
||||||
|
export const walletType = 'LND'
|
||||||
|
|
||||||
|
export const walletField = 'walletLND'
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { ensureB64 } from '@/lib/format'
|
||||||
|
import { datePivot } from '@/lib/time'
|
||||||
|
import { authenticatedLndGrpc, createInvoice as lndCreateInvoice } from 'ln-service'
|
||||||
|
import { addWalletLog } from '@/api/resolvers/wallet'
|
||||||
|
|
||||||
|
export * from 'wallets/lnd'
|
||||||
|
|
||||||
|
export const testConnect = async (
|
||||||
|
{ cert, macaroon, socket },
|
||||||
|
{ me, models }
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
cert = ensureB64(cert)
|
||||||
|
macaroon = ensureB64(macaroon)
|
||||||
|
|
||||||
|
const { lnd } = await authenticatedLndGrpc({
|
||||||
|
cert,
|
||||||
|
macaroon,
|
||||||
|
socket
|
||||||
|
})
|
||||||
|
|
||||||
|
const inv = await lndCreateInvoice({
|
||||||
|
description: 'SN connection test',
|
||||||
|
lnd,
|
||||||
|
tokens: 0,
|
||||||
|
expires_at: new Date()
|
||||||
|
})
|
||||||
|
|
||||||
|
// we wrap both calls in one try/catch since connection attempts happen on RPC calls
|
||||||
|
await addWalletLog({ wallet: { type: 'LND' }, level: 'SUCCESS', message: 'connected to LND' }, { me, models })
|
||||||
|
|
||||||
|
return inv
|
||||||
|
} catch (err) {
|
||||||
|
// LND errors are in this shape: [code, type, { err: { code, details, metadata } }]
|
||||||
|
const details = err[2]?.err?.details || err.message || err.toString?.()
|
||||||
|
throw new Error(details)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createInvoice = async (
|
||||||
|
{ amount },
|
||||||
|
{ cert, macaroon, socket },
|
||||||
|
{ me }
|
||||||
|
) => {
|
||||||
|
const { lnd } = await authenticatedLndGrpc({
|
||||||
|
cert,
|
||||||
|
macaroon,
|
||||||
|
socket
|
||||||
|
})
|
||||||
|
|
||||||
|
const invoice = await lndCreateInvoice({
|
||||||
|
description: me.hideInvoiceDesc ? undefined : 'autowithdraw to LND from SN',
|
||||||
|
lnd,
|
||||||
|
tokens: amount,
|
||||||
|
expires_at: datePivot(new Date(), { seconds: 360 })
|
||||||
|
})
|
||||||
|
|
||||||
|
return invoice.request
|
||||||
|
}
|
|
@ -0,0 +1,118 @@
|
||||||
|
import { parseNwcUrl } from '@/lib/url'
|
||||||
|
import { Relay, finalizeEvent, nip04 } from 'nostr-tools'
|
||||||
|
|
||||||
|
export * from 'wallets/nwc'
|
||||||
|
|
||||||
|
export async function validate ({ nwcUrl }, { logger }) {
|
||||||
|
const { relayUrl, walletPubkey } = parseNwcUrl(nwcUrl)
|
||||||
|
|
||||||
|
logger.info(`requesting info event from ${relayUrl}`)
|
||||||
|
const relay = await Relay
|
||||||
|
.connect(relayUrl)
|
||||||
|
.catch(() => {
|
||||||
|
// NOTE: passed error is undefined for some reason
|
||||||
|
const msg = `failed to connect to ${relayUrl}`
|
||||||
|
logger.error(msg)
|
||||||
|
throw new Error(msg)
|
||||||
|
})
|
||||||
|
logger.ok(`connected to ${relayUrl}`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
let found = false
|
||||||
|
const sub = relay.subscribe([
|
||||||
|
{
|
||||||
|
kinds: [13194],
|
||||||
|
authors: [walletPubkey]
|
||||||
|
}
|
||||||
|
], {
|
||||||
|
onevent (event) {
|
||||||
|
found = true
|
||||||
|
logger.ok(`received info event from ${relayUrl}`)
|
||||||
|
resolve(event)
|
||||||
|
},
|
||||||
|
onclose (reason) {
|
||||||
|
if (!['closed by caller', 'relay connection closed by us'].includes(reason)) {
|
||||||
|
// only log if not closed by us (caller)
|
||||||
|
const msg = 'connection closed: ' + (reason || 'unknown reason')
|
||||||
|
logger.error(msg)
|
||||||
|
reject(new Error(msg))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
oneose () {
|
||||||
|
if (!found) {
|
||||||
|
const msg = 'EOSE received without info event'
|
||||||
|
logger.error(msg)
|
||||||
|
reject(new Error(msg))
|
||||||
|
}
|
||||||
|
sub?.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
// For some reason, this throws 'WebSocket is already in CLOSING or CLOSED state'
|
||||||
|
// even though relay connection is still open here
|
||||||
|
relay?.close()?.catch()
|
||||||
|
if (relay) logger.info(`closed connection to ${relayUrl}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendPayment (bolt11, { nwcUrl }, { logger }) {
|
||||||
|
const { relayUrl, walletPubkey, secret } = parseNwcUrl(nwcUrl)
|
||||||
|
|
||||||
|
const relay = await Relay.connect(relayUrl).catch(() => {
|
||||||
|
// NOTE: passed error is undefined for some reason
|
||||||
|
throw new Error(`failed to connect to ${relayUrl}`)
|
||||||
|
})
|
||||||
|
logger.ok(`connected to ${relayUrl}`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ret = await new Promise(function (resolve, reject) {
|
||||||
|
(async function () {
|
||||||
|
const payload = {
|
||||||
|
method: 'pay_invoice',
|
||||||
|
params: { invoice: bolt11 }
|
||||||
|
}
|
||||||
|
const content = await nip04.encrypt(secret, walletPubkey, JSON.stringify(payload))
|
||||||
|
|
||||||
|
const request = finalizeEvent({
|
||||||
|
kind: 23194,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [['p', walletPubkey]],
|
||||||
|
content
|
||||||
|
}, secret)
|
||||||
|
await relay.publish(request)
|
||||||
|
|
||||||
|
const filter = {
|
||||||
|
kinds: [23195],
|
||||||
|
authors: [walletPubkey],
|
||||||
|
'#e': [request.id]
|
||||||
|
}
|
||||||
|
relay.subscribe([filter], {
|
||||||
|
async onevent (response) {
|
||||||
|
try {
|
||||||
|
const content = JSON.parse(await nip04.decrypt(secret, walletPubkey, response.content))
|
||||||
|
if (content.error) return reject(new Error(content.error.message))
|
||||||
|
if (content.result) return resolve({ preimage: content.result.preimage })
|
||||||
|
} catch (err) {
|
||||||
|
return reject(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onclose (reason) {
|
||||||
|
if (!['closed by caller', 'relay connection closed by us'].includes(reason)) {
|
||||||
|
// only log if not closed by us (caller)
|
||||||
|
const msg = 'connection closed: ' + (reason || 'unknown reason')
|
||||||
|
reject(new Error(msg))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})().catch(reject)
|
||||||
|
})
|
||||||
|
return ret
|
||||||
|
} finally {
|
||||||
|
// For some reason, this throws 'WebSocket is already in CLOSING or CLOSED state'
|
||||||
|
// even though relay connection is still open here
|
||||||
|
relay?.close()?.catch()
|
||||||
|
if (relay) logger.info(`closed connection to ${relayUrl}`)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { NOSTR_PUBKEY_HEX } from '@/lib/nostr'
|
||||||
|
import { parseNwcUrl } from '@/lib/url'
|
||||||
|
import { string } from 'yup'
|
||||||
|
|
||||||
|
export const name = 'nwc'
|
||||||
|
|
||||||
|
export const fields = [
|
||||||
|
{
|
||||||
|
name: 'nwcUrl',
|
||||||
|
label: 'connection',
|
||||||
|
type: 'password',
|
||||||
|
validate: {
|
||||||
|
type: 'string',
|
||||||
|
test: async (nwcUrl, context) => {
|
||||||
|
// run validation in sequence to control order of errors
|
||||||
|
// inspired by https://github.com/jquense/yup/issues/851#issuecomment-1049705180
|
||||||
|
try {
|
||||||
|
await string().required('required').validate(nwcUrl)
|
||||||
|
await string().matches(/^nostr\+?walletconnect:\/\//, { message: 'must start with nostr+walletconnect://' }).validate(nwcUrl)
|
||||||
|
let relayUrl, walletPubkey, secret
|
||||||
|
try {
|
||||||
|
({ relayUrl, 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')
|
||||||
|
}
|
||||||
|
await string().required('pubkey required').trim().matches(NOSTR_PUBKEY_HEX, 'pubkey must be 64 hex chars').validate(walletPubkey)
|
||||||
|
await string().required('relay url required').trim().wss('relay must use wss://').validate(relayUrl)
|
||||||
|
await string().required('secret required').trim().matches(/^[0-9a-fA-F]{64}$/, 'secret must be 64 hex chars').validate(secret)
|
||||||
|
} catch (err) {
|
||||||
|
return context.createError({ message: err.message })
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export const card = {
|
||||||
|
title: 'NWC',
|
||||||
|
subtitle: 'use Nostr Wallet Connect for payments',
|
||||||
|
badges: ['send only', 'non-custodialish']
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"type": "module"
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
import * as lnd from 'wallets/lnd/server'
|
||||||
|
import * as cln from 'wallets/cln/server'
|
||||||
|
import * as lnAddr from 'wallets/lightning-address/server'
|
||||||
|
|
||||||
|
export default [lnd, cln, lnAddr]
|
|
@ -1,9 +1,6 @@
|
||||||
import { authenticatedLndGrpc, createInvoice } from 'ln-service'
|
|
||||||
import { msatsToSats, satsToMsats } from '@/lib/format'
|
import { msatsToSats, satsToMsats } from '@/lib/format'
|
||||||
import { datePivot } from '@/lib/time'
|
import { createWithdrawal, addWalletLog } from '@/api/resolvers/wallet'
|
||||||
import { createWithdrawal, sendToLnAddr, addWalletLog } from '@/api/resolvers/wallet'
|
import walletDefs from 'wallets/server'
|
||||||
import { createInvoice as createInvoiceCLN } from '@/lib/cln'
|
|
||||||
import { Wallet } from '@/lib/constants'
|
|
||||||
|
|
||||||
export async function autoWithdraw ({ data: { id }, models, lnd }) {
|
export async function autoWithdraw ({ data: { id }, models, lnd }) {
|
||||||
const user = await models.user.findUnique({ where: { id } })
|
const user = await models.user.findUnique({ where: { id } })
|
||||||
|
@ -41,29 +38,28 @@ export async function autoWithdraw ({ data: { id }, models, lnd }) {
|
||||||
|
|
||||||
// get the wallets in order of priority
|
// get the wallets in order of priority
|
||||||
const wallets = await models.wallet.findMany({
|
const wallets = await models.wallet.findMany({
|
||||||
where: { userId: user.id },
|
where: { userId: user.id, enabled: true },
|
||||||
orderBy: { priority: 'desc' }
|
orderBy: [
|
||||||
|
{ priority: 'asc' },
|
||||||
|
// use id as tie breaker (older wallet first)
|
||||||
|
{ id: 'asc' }
|
||||||
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
for (const wallet of wallets) {
|
for (const wallet of wallets) {
|
||||||
|
const w = walletDefs.find(w => w.walletType === wallet.type)
|
||||||
try {
|
try {
|
||||||
if (wallet.type === Wallet.LND.type) {
|
const { walletType, walletField, createInvoice } = w
|
||||||
await autowithdrawLND(
|
return await autowithdraw(
|
||||||
{ amount, maxFee },
|
{ walletType, walletField, createInvoice },
|
||||||
{ models, me: user, lnd })
|
{ amount, maxFee },
|
||||||
} else if (wallet.type === Wallet.CLN.type) {
|
{ me: user, models, lnd }
|
||||||
await autowithdrawCLN(
|
)
|
||||||
{ amount, maxFee },
|
|
||||||
{ models, me: user, lnd })
|
|
||||||
} else if (wallet.type === Wallet.LnAddr.type) {
|
|
||||||
await autowithdrawLNAddr(
|
|
||||||
{ amount, maxFee },
|
|
||||||
{ models, me: user, lnd })
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
|
|
||||||
|
// TODO: I think this is a bug, `walletCreateInvoice` in `autowithdraw` should parse the error
|
||||||
|
|
||||||
// LND errors are in this shape: [code, type, { err: { code, details, metadata } }]
|
// LND errors are in this shape: [code, type, { err: { code, details, metadata } }]
|
||||||
const details = error[2]?.err?.details || error.message || error.toString?.()
|
const details = error[2]?.err?.details || error.message || error.toString?.()
|
||||||
await addWalletLog({
|
await addWalletLog({
|
||||||
|
@ -77,9 +73,10 @@ export async function autoWithdraw ({ data: { id }, models, lnd }) {
|
||||||
// none of the wallets worked
|
// none of the wallets worked
|
||||||
}
|
}
|
||||||
|
|
||||||
async function autowithdrawLNAddr (
|
async function autowithdraw (
|
||||||
|
{ walletType, walletField, createInvoice: walletCreateInvoice },
|
||||||
{ amount, maxFee },
|
{ amount, maxFee },
|
||||||
{ me, models, lnd, headers, autoWithdraw = false }) {
|
{ me, models, lnd }) {
|
||||||
if (!me) {
|
if (!me) {
|
||||||
throw new Error('me not specified')
|
throw new Error('me not specified')
|
||||||
}
|
}
|
||||||
|
@ -87,86 +84,25 @@ async function autowithdrawLNAddr (
|
||||||
const wallet = await models.wallet.findFirst({
|
const wallet = await models.wallet.findFirst({
|
||||||
where: {
|
where: {
|
||||||
userId: me.id,
|
userId: me.id,
|
||||||
type: Wallet.LnAddr.type
|
type: walletType
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
walletLightningAddress: true
|
[walletField]: true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!wallet || !wallet.walletLightningAddress) {
|
if (!wallet || !wallet[walletField]) {
|
||||||
throw new Error('no lightning address wallet found')
|
throw new Error(`no ${walletType} wallet found`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { walletLightningAddress: { address } } = wallet
|
const bolt11 = await walletCreateInvoice(
|
||||||
return await sendToLnAddr(null, { addr: address, amount, maxFee }, { me, models, lnd, walletId: wallet.id })
|
{ amount, maxFee },
|
||||||
}
|
wallet[walletField],
|
||||||
|
{
|
||||||
async function autowithdrawLND ({ amount, maxFee }, { me, models, lnd }) {
|
me,
|
||||||
if (!me) {
|
models,
|
||||||
throw new Error('me not specified')
|
lnd
|
||||||
}
|
})
|
||||||
|
|
||||||
const wallet = await models.wallet.findFirst({
|
return await createWithdrawal(null, { invoice: bolt11, maxFee }, { me, models, lnd, walletId: wallet.id })
|
||||||
where: {
|
|
||||||
userId: me.id,
|
|
||||||
type: Wallet.LND.type
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
walletLND: true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!wallet || !wallet.walletLND) {
|
|
||||||
throw new Error('no lnd wallet found')
|
|
||||||
}
|
|
||||||
|
|
||||||
const { walletLND: { cert, macaroon, socket } } = wallet
|
|
||||||
const { lnd: lndOut } = await authenticatedLndGrpc({
|
|
||||||
cert,
|
|
||||||
macaroon,
|
|
||||||
socket
|
|
||||||
})
|
|
||||||
|
|
||||||
const invoice = await createInvoice({
|
|
||||||
description: me.hideInvoiceDesc ? undefined : 'autowithdraw to LND from SN',
|
|
||||||
lnd: lndOut,
|
|
||||||
tokens: amount,
|
|
||||||
expires_at: datePivot(new Date(), { seconds: 360 })
|
|
||||||
})
|
|
||||||
|
|
||||||
return await createWithdrawal(null, { invoice: invoice.request, maxFee }, { me, models, lnd, walletId: wallet.id })
|
|
||||||
}
|
|
||||||
|
|
||||||
async function autowithdrawCLN ({ amount, maxFee }, { me, models, lnd }) {
|
|
||||||
if (!me) {
|
|
||||||
throw new Error('me not specified')
|
|
||||||
}
|
|
||||||
|
|
||||||
const wallet = await models.wallet.findFirst({
|
|
||||||
where: {
|
|
||||||
userId: me.id,
|
|
||||||
type: Wallet.CLN.type
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
walletCLN: true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!wallet || !wallet.walletCLN) {
|
|
||||||
throw new Error('no cln wallet found')
|
|
||||||
}
|
|
||||||
|
|
||||||
const { walletCLN: { cert, rune, socket } } = wallet
|
|
||||||
|
|
||||||
const inv = await createInvoiceCLN({
|
|
||||||
socket,
|
|
||||||
rune,
|
|
||||||
cert,
|
|
||||||
description: me.hideInvoiceDesc ? undefined : 'autowithdraw to CLN from SN',
|
|
||||||
msats: amount + 'sat',
|
|
||||||
expiry: 360
|
|
||||||
})
|
|
||||||
|
|
||||||
return await createWithdrawal(null, { invoice: inv.bolt11, maxFee }, { me, models, lnd, walletId: wallet.id })
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -253,7 +253,7 @@ async function checkWithdrawal ({ data: { hash }, boss, models, lnd }) {
|
||||||
if (dbWdrwl.wallet) {
|
if (dbWdrwl.wallet) {
|
||||||
// this was an autowithdrawal
|
// this was an autowithdrawal
|
||||||
const message = `autowithdrawal of ${numWithUnits(msatsToSats(paid), { abbreviate: false })} with ${numWithUnits(msatsToSats(fee), { abbreviate: false })} as fee`
|
const message = `autowithdrawal of ${numWithUnits(msatsToSats(paid), { abbreviate: false })} with ${numWithUnits(msatsToSats(fee), { abbreviate: false })} as fee`
|
||||||
await addWalletLog({ wallet: dbWdrwl.wallet.type, level: 'SUCCESS', message }, { models, me: { id: dbWdrwl.userId } })
|
await addWalletLog({ wallet: dbWdrwl.wallet, level: 'SUCCESS', message }, { models, me: { id: dbWdrwl.userId } })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (wdrwl?.is_failed || notFound) {
|
} else if (wdrwl?.is_failed || notFound) {
|
||||||
|
@ -281,7 +281,7 @@ async function checkWithdrawal ({ data: { hash }, boss, models, lnd }) {
|
||||||
if (code === 0 && dbWdrwl.wallet) {
|
if (code === 0 && dbWdrwl.wallet) {
|
||||||
// add error into log for autowithdrawal
|
// add error into log for autowithdrawal
|
||||||
await addWalletLog({
|
await addWalletLog({
|
||||||
wallet: dbWdrwl.wallet.type,
|
wallet: dbWdrwl.wallet,
|
||||||
level: 'ERROR',
|
level: 'ERROR',
|
||||||
message: 'autowithdrawal failed: ' + message
|
message: 'autowithdrawal failed: ' + message
|
||||||
}, { models, me: { id: dbWdrwl.userId } })
|
}, { models, me: { id: dbWdrwl.userId } })
|
||||||
|
|
Loading…
Reference in New Issue