Compare commits
4 Commits
3a748b8d38
...
371e7417ce
Author | SHA1 | Date | |
---|---|---|---|
|
371e7417ce | ||
|
cadfc47eb5 | ||
|
c025c85855 | ||
|
573d4d8452 |
@ -1,5 +1,6 @@
|
|||||||
PRISMA_SLOW_LOGS_MS=
|
PRISMA_SLOW_LOGS_MS=
|
||||||
GRAPHQL_SLOW_LOGS_MS=
|
GRAPHQL_SLOW_LOGS_MS=
|
||||||
|
NODE_ENV=development
|
||||||
|
|
||||||
############################################################################
|
############################################################################
|
||||||
# OPTIONAL SECRETS #
|
# OPTIONAL SECRETS #
|
||||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -20,7 +20,6 @@ node_modules/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
*.pem
|
*.pem
|
||||||
/*.sql
|
/*.sql
|
||||||
lnbits/
|
|
||||||
|
|
||||||
# debug
|
# debug
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
@ -141,6 +141,7 @@ export default {
|
|||||||
(SELECT FLOOR("Earn".msats / 1000.0) as sats, type, rank, "typeId"
|
(SELECT FLOOR("Earn".msats / 1000.0) as sats, type, rank, "typeId"
|
||||||
FROM "Earn"
|
FROM "Earn"
|
||||||
WHERE "Earn"."userId" = ${me.id}
|
WHERE "Earn"."userId" = ${me.id}
|
||||||
|
AND (type IS NULL OR type NOT IN ('FOREVER_REFERRAL', 'ONE_DAY_REFERRAL'))
|
||||||
AND date_trunc('day', "Earn".created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = days_cte.day
|
AND date_trunc('day', "Earn".created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = days_cte.day
|
||||||
ORDER BY "Earn".msats DESC)
|
ORDER BY "Earn".msats DESC)
|
||||||
) "Earn"
|
) "Earn"
|
||||||
|
@ -1,19 +1,45 @@
|
|||||||
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, formikValidate } 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 } from '@/lib/wallet'
|
||||||
|
import { lnAddrOptions } from '@/lib/lnurl'
|
||||||
|
|
||||||
|
function injectResolvers (resolvers) {
|
||||||
|
console.group('injected GraphQL resolvers:')
|
||||||
|
for (const w of walletDefs) {
|
||||||
|
const { fieldValidation, walletType, walletField, testConnectServer } = w
|
||||||
|
const resolverName = generateResolverName(walletField)
|
||||||
|
console.log(resolverName)
|
||||||
|
|
||||||
|
// check if wallet uses the form-level validation built into Formik or a Yup schema
|
||||||
|
const validateArgs = typeof fieldValidation === 'function'
|
||||||
|
? { formikValidate: fieldValidation }
|
||||||
|
: { schema: fieldValidation }
|
||||||
|
|
||||||
|
resolvers.Mutation[resolverName] = async (parent, { settings, ...data }, { me, models }) => {
|
||||||
|
return await upsertWallet({
|
||||||
|
...validateArgs,
|
||||||
|
wallet: { field: walletField, type: walletType },
|
||||||
|
testConnectServer: (data) => testConnectServer(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 +119,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 +344,10 @@ export default {
|
|||||||
where: {
|
where: {
|
||||||
userId: me.id
|
userId: me.id
|
||||||
},
|
},
|
||||||
orderBy: {
|
orderBy: [
|
||||||
createdAt: 'asc'
|
{ createdAt: 'desc' },
|
||||||
}
|
{ id: 'desc' }
|
||||||
|
]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -423,85 +450,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 +462,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 +546,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 } })
|
||||||
@ -607,26 +557,32 @@ export const addWalletLog = async ({ wallet, level, message }, { me, models }) =
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function upsertWallet (
|
async function upsertWallet (
|
||||||
{ schema, wallet, testConnect }, { settings, data }, { me, models }) {
|
{ schema, formikValidate: validate, wallet, testConnectServer }, { settings, data }, { 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' } })
|
||||||
}
|
}
|
||||||
assertApiKeyNotPermitted({ me })
|
assertApiKeyNotPermitted({ me })
|
||||||
|
|
||||||
|
if (schema) {
|
||||||
await ssValidate(schema, { ...data, ...settings }, { me, models })
|
await ssValidate(schema, { ...data, ...settings }, { me, models })
|
||||||
|
}
|
||||||
|
if (validate) {
|
||||||
|
await formikValidate(validate, { ...data, ...settings })
|
||||||
|
}
|
||||||
|
|
||||||
if (testConnect) {
|
if (testConnectServer) {
|
||||||
try {
|
try {
|
||||||
await testConnect(data)
|
await testConnectServer(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 +594,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 +608,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 +708,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 +767,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 +782,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>
|
||||||
|
@ -802,7 +802,7 @@ export function CheckboxGroup ({ label, groupClassName, children, ...props }) {
|
|||||||
const StorageKeyPrefixContext = createContext()
|
const StorageKeyPrefixContext = createContext()
|
||||||
|
|
||||||
export function Form ({
|
export function Form ({
|
||||||
initial, schema, onSubmit, children, initialError, validateImmediately,
|
initial, validate, schema, onSubmit, children, initialError, validateImmediately,
|
||||||
storageKeyPrefix, validateOnChange = true, requireSession, innerRef,
|
storageKeyPrefix, validateOnChange = true, requireSession, innerRef,
|
||||||
...props
|
...props
|
||||||
}) {
|
}) {
|
||||||
@ -856,6 +856,7 @@ export function Form ({
|
|||||||
<Formik
|
<Formik
|
||||||
initialValues={initial}
|
initialValues={initial}
|
||||||
validateOnChange={validateOnChange}
|
validateOnChange={validateOnChange}
|
||||||
|
validate={validate}
|
||||||
validationSchema={schema}
|
validationSchema={schema}
|
||||||
initialTouched={validateImmediately && initial}
|
initialTouched={validateImmediately && initial}
|
||||||
validateOnBlur={false}
|
validateOnBlur={false}
|
||||||
|
@ -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
|
||||||
|
@ -3,6 +3,10 @@
|
|||||||
border: solid 1px var(--theme-note-fresh);
|
border: solid 1px var(--theme-note-fresh);
|
||||||
border-bottom: 0;
|
border-bottom: 0;
|
||||||
border-top: 0;
|
border-top: 0;
|
||||||
|
margin-left: -0.5rem;
|
||||||
|
padding-left: 0.5rem;
|
||||||
|
margin-right: -0.5rem;
|
||||||
|
padding-right: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fresh:not(.fresh ~ .fresh) {
|
.fresh:not(.fresh ~ .fresh) {
|
||||||
|
@ -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 (
|
||||||
<>
|
<>
|
||||||
|
21
components/use-local-state.js
Normal file
21
components/use-local-state.js
Normal file
@ -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,30 @@ 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, { alwaysShowQROnFailure = false, 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 (
|
||||||
|
(!alwaysShowQROnFailure && 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
|
||||||
@ -89,7 +92,7 @@ export function usePaidMutation (mutation,
|
|||||||
// the action is pessimistic
|
// the action is pessimistic
|
||||||
try {
|
try {
|
||||||
// wait for the invoice to be paid
|
// wait for the invoice to be paid
|
||||||
await waitForPayment(invoice, { persistOnNavigate, waitFor: inv => inv?.actionState === 'PAID' })
|
await waitForPayment(invoice, { alwaysShowQROnFailure: true, persistOnNavigate, waitFor: inv => inv?.actionState === 'PAID' })
|
||||||
if (!response.result) {
|
if (!response.result) {
|
||||||
// if the mutation didn't return any data, ie pessimistic, we need to fetch it
|
// if the mutation didn't return any data, ie pessimistic, we need to fetch it
|
||||||
const { data: { paidAction } } = await getPaidAction({ variables: { invoiceId: parseInt(invoice.id) } })
|
const { data: { paidAction } } = await getPaidAction({ variables: { invoiceId: parseInt(invoice.id) } })
|
||||||
|
23
components/wallet-buttonbar.js
Normal file
23
components/wallet-buttonbar.js
Normal file
@ -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}>
|
||||||
{configured
|
{wallet.isConfigured
|
||||||
? <>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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
274
components/wallet-logger.js
Normal file
274
components/wallet-logger.js
Normal file
@ -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 []
|
||||||
|
@ -33,6 +33,14 @@ export async function ssValidate (schema, data, args) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function formikValidate (validate, data) {
|
||||||
|
const errors = await validate(data)
|
||||||
|
if (Object.keys(errors).length > 0) {
|
||||||
|
const [key, message] = Object.entries(errors)[0]
|
||||||
|
throw new Error(`${key}: ${message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
addMethod(string, 'or', function (schemas, msg) {
|
addMethod(string, 'or', function (schemas, msg) {
|
||||||
return this.test({
|
return this.test({
|
||||||
name: 'or',
|
name: 'or',
|
||||||
@ -153,7 +161,7 @@ const floatValidator = number().typeError('must be a number')
|
|||||||
|
|
||||||
const lightningAddressValidator = process.env.NODE_ENV === 'development'
|
const lightningAddressValidator = process.env.NODE_ENV === 'development'
|
||||||
? string().or(
|
? string().or(
|
||||||
[string().matches(/^[\w_]+@localhost:\d+$/), string().email()],
|
[string().matches(/^[\w_]+@localhost:\d+$/), string().matches(/^[\w_]+@app:\d+$/), string().email()],
|
||||||
'address is no good')
|
'address is no good')
|
||||||
: string().email('address is no good')
|
: string().email('address is no good')
|
||||||
|
|
||||||
@ -305,19 +313,22 @@ export function advSchema (args) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function lnAddrAutowithdrawSchema ({ me } = {}) {
|
export const autowithdrawSchemaMembers = {
|
||||||
return object({
|
enabled: 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 const lnAddrAutowithdrawSchema = object({
|
||||||
address: lightningAddressValidator.required('required').test({
|
address: lightningAddressValidator.required('required').test({
|
||||||
name: 'address',
|
name: 'address',
|
||||||
test: addr => !addr.endsWith('@stacker.news'),
|
test: addr => !addr.endsWith('@stacker.news'),
|
||||||
message: 'automated withdrawals must be external'
|
message: 'automated withdrawals must be external'
|
||||||
}),
|
}),
|
||||||
...autowithdrawSchemaMembers({ me })
|
...autowithdrawSchemaMembers
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
export function LNDAutowithdrawSchema ({ me } = {}) {
|
export const LNDAutowithdrawSchema = object({
|
||||||
return object({
|
|
||||||
socket: string().socket().required('required'),
|
socket: string().socket().required('required'),
|
||||||
macaroon: hexOrBase64Validator.required('required').test({
|
macaroon: hexOrBase64Validator.required('required').test({
|
||||||
name: 'macaroon',
|
name: 'macaroon',
|
||||||
@ -325,12 +336,10 @@ export function LNDAutowithdrawSchema ({ me } = {}) {
|
|||||||
message: 'not an invoice macaroon or an invoicable macaroon'
|
message: 'not an invoice macaroon or an invoicable macaroon'
|
||||||
}),
|
}),
|
||||||
cert: hexOrBase64Validator,
|
cert: hexOrBase64Validator,
|
||||||
...autowithdrawSchemaMembers({ me })
|
...autowithdrawSchemaMembers
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
export function CLNAutowithdrawSchema ({ me } = {}) {
|
export const CLNAutowithdrawSchema = object({
|
||||||
return object({
|
|
||||||
socket: string().socket().required('required'),
|
socket: string().socket().required('required'),
|
||||||
rune: string().matches(B64_URL_REGEX, { message: 'invalid rune' }).required('required')
|
rune: string().matches(B64_URL_REGEX, { message: 'invalid rune' }).required('required')
|
||||||
.test({
|
.test({
|
||||||
@ -351,17 +360,8 @@ export function CLNAutowithdrawSchema ({ me } = {}) {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
cert: hexOrBase64Validator,
|
cert: hexOrBase64Validator,
|
||||||
...autowithdrawSchemaMembers({ me })
|
...autowithdrawSchemaMembers
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
||||||
return object({
|
return object({
|
||||||
@ -622,7 +622,7 @@ export const lnbitsSchema = object({
|
|||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}),
|
}),
|
||||||
adminKey: string().length(32)
|
adminKey: string().length(32).required('required')
|
||||||
})
|
})
|
||||||
|
|
||||||
export const nwcSchema = object({
|
export const nwcSchema = object({
|
||||||
@ -657,13 +657,21 @@ export const lncSchema = object({
|
|||||||
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')
|
||||||
password: string()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export const bioSchema = object({
|
export const bioSchema = object({
|
||||||
|
4
lib/wallet.js
Normal file
4
lib/wallet.js
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export function generateResolverName (walletField) {
|
||||||
|
const capitalized = walletField[0].toUpperCase() + walletField.slice(1)
|
||||||
|
return `upsertWallet${capitalized}`
|
||||||
|
}
|
@ -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>
|
||||||
|
<WalletLoggerProvider>
|
||||||
<ServiceWorkerProvider>
|
<ServiceWorkerProvider>
|
||||||
<PriceProvider price={price}>
|
<PriceProvider price={price}>
|
||||||
<LightningProvider>
|
<LightningProvider>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<WebLNProvider>
|
|
||||||
<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>
|
||||||
|
@ -9,8 +9,9 @@ export default async ({ query: { username } }, res) => {
|
|||||||
return res.status(400).json({ status: 'ERROR', reason: `user @${username} does not exist` })
|
return res.status(400).json({ status: 'ERROR', reason: `user @${username} does not exist` })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const url = process.env.NODE_ENV === 'development' ? process.env.SELF_URL : process.env.NEXT_PUBLIC_URL
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
callback: `${process.env.NEXT_PUBLIC_URL}/api/lnurlp/${username}/pay`, // The URL from LN SERVICE which will accept the pay request parameters
|
callback: `${url}/api/lnurlp/${username}/pay`, // The URL from LN SERVICE which will accept the pay request parameters
|
||||||
minSendable: 1000, // Min amount LN SERVICE is willing to receive, can not be less than 1 or more than `maxSendable`
|
minSendable: 1000, // Min amount LN SERVICE is willing to receive, can not be less than 1 or more than `maxSendable`
|
||||||
maxSendable: 1000000000,
|
maxSendable: 1000000000,
|
||||||
metadata: lnurlPayMetadataString(username), // Metadata json which must be presented as raw string here, this is required to pass signature verification at a later step
|
metadata: lnurlPayMetadataString(username), // Metadata json which must be presented as raw string here, this is required to pass signature verification at a later step
|
||||||
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
142
pages/settings/wallets/[wallet].js
Normal file
142
pages/settings/wallets/[wallet].js
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
// check if wallet uses the form-level validation built into Formik or a Yup schema
|
||||||
|
const validateProps = typeof wallet.fieldValidation === 'function'
|
||||||
|
? { validate: wallet.fieldValidation }
|
||||||
|
: { schema: wallet.fieldValidation }
|
||||||
|
|
||||||
|
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}
|
||||||
|
{...validateProps}
|
||||||
|
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;
|
||||||
|
236
wallets/README.md
Normal file
236
wallets/README.md
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
# Wallets
|
||||||
|
|
||||||
|
Every wallet that you can see at [/settings/wallets](https://stacker.news/settings/wallets) is implemented as a plugin in this directory.
|
||||||
|
|
||||||
|
This README explains how you can add another wallet for use with Stacker News.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Plugin means here that you only have to implement a common interface in this directory to add a wallet.
|
||||||
|
|
||||||
|
## Plugin interface
|
||||||
|
|
||||||
|
Every wallet is defined inside its own directory. Every directory must contain an _index.js_ and a _client.js_ file.
|
||||||
|
|
||||||
|
An index.js file exports properties that can be shared by the client and server.
|
||||||
|
|
||||||
|
Wallets that have spending permissions / can pay invoices export the payment interface in client.js. These permissions are stored on the client.[^1]
|
||||||
|
|
||||||
|
[^1]: unencrypted in local storage until we have implemented encrypted local storage.
|
||||||
|
|
||||||
|
A _server.js_ file is only required for wallets that support receiving by exposing the corresponding interface in that file. These wallets are stored on the server because payments are coordinated on the server so the server needs to generate these invoices for receiving. Additionally, permissions to receive a payment are not as sensitive as permissions to send a payment (highly sensitive!).
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Every wallet must have a client.js file (even if it does not support paying invoices) because every wallet is imported on the client. This is not the case on the server. On the client, wallets are imported via
|
||||||
|
>
|
||||||
|
> ```js
|
||||||
|
> import wallet from 'wallets/<name>/client'
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> vs
|
||||||
|
>
|
||||||
|
> ```js
|
||||||
|
> import wallet from 'wallets/<name>/server'
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> on the server.
|
||||||
|
>
|
||||||
|
> To have access to the properties that can be shared between client and server, server.js and client.js always reexport everything in index.js with a line like this:
|
||||||
|
>
|
||||||
|
> ```js
|
||||||
|
> export * from 'wallets/<name>'
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> If a wallet does not support paying invoices, this is all that client.js of this wallet does. The reason for this structure is to make sure the client does not import dependencies that can only be imported on the server and would thus break the build.
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> Wallets that support spending **AND** receiving have not been tested yet. For now, only implement either the interface for spending **OR** receiving until this warning is removed.
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> Don't hesitate to use the implementation of existing wallets as a reference.
|
||||||
|
|
||||||
|
### index.js
|
||||||
|
|
||||||
|
An index.js file exports the following properties that are shared by imports of this wallet on the server and wallet:
|
||||||
|
|
||||||
|
- `name: string`
|
||||||
|
|
||||||
|
This acts as an ID for this wallet on the client. It therefore must be unique across all wallets and is used throughout the code to reference this wallet. This name is also shown in the [wallet logs](https://stacker.news/wallet/logs).
|
||||||
|
|
||||||
|
- `shortName?: string`
|
||||||
|
|
||||||
|
Since `name` will also be used in [wallet logs](https://stacker.news/wallet/logs), you can specify a shorter name here which will be used in logs instead.
|
||||||
|
|
||||||
|
- `fields: WalletField[]`
|
||||||
|
|
||||||
|
Wallet fields define what this wallet requires for configuration and thus are used to construct the forms like the one you can see at [/settings/wallets/lnbits](https://stacker.news/settings/walletslnbits).
|
||||||
|
|
||||||
|
- `card: WalletCard`
|
||||||
|
|
||||||
|
Wallet cards are the components you can see at [/settings/wallets](https://stacker.news/settings/wallets). This property customizes this card for this wallet.
|
||||||
|
|
||||||
|
- `fieldValidation: (config) => { [key: string]: string } | Yup.ObjectSchema`
|
||||||
|
|
||||||
|
This property defines how Formik should perform form-level validation. As mentioned in the [documentation](https://formik.org/docs/guides/validation#form-level-validation), Formik supports two ways to perform such validation.
|
||||||
|
|
||||||
|
If a function is used for `fieldValidation`, the built-in form-level validation is used via the [`validate`](https://formik.org/docs/guides/validation#validate) property of the Formik form component.
|
||||||
|
|
||||||
|
If a [Yup object schema](https://github.com/jquense/yup?tab=readme-ov-file#object) is set, [`validationSchema`](https://formik.org/docs/guides/validation#validationschema) will be used instead.
|
||||||
|
|
||||||
|
This validation is triggered on every submit and on every change after the first submit attempt.
|
||||||
|
|
||||||
|
Refer to the [Formik documentation](https://formik.org/docs/guides/validation) for more details.
|
||||||
|
|
||||||
|
- `walletType?: string`
|
||||||
|
|
||||||
|
This field is only required if this wallet supports receiving payments. It must match a value of the enum `WalletType` in the database.
|
||||||
|
|
||||||
|
- `walletField?: string`
|
||||||
|
|
||||||
|
Just like `walletType`, this field is only required if this wallet supports receiving payments. It must match a column in the `Wallet` table.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> This is the only exception where you have to write code outside this directory for a wallet that supports receiving: you need to write a database migration to add a new enum value to `WalletType` and column to `Wallet`. See the top-level [README](../README.md#database-migrations) for how to do this.
|
||||||
|
|
||||||
|
#### WalletField
|
||||||
|
|
||||||
|
A wallet field is an object with the following properties:
|
||||||
|
|
||||||
|
- `name: string`
|
||||||
|
|
||||||
|
The configuration key. This is used by [Formik](https://formik.org/docs/overview) to map values to the correct input. This key is also what is used to save values in local storage or the database. For wallets that are stored on the server, this must therefore match a column in the corresponding table for wallets of this type.
|
||||||
|
|
||||||
|
- `label: string`
|
||||||
|
|
||||||
|
The label of the configuration key. Will be shown to the user in the form.
|
||||||
|
|
||||||
|
- `type: 'text' | 'password'`
|
||||||
|
|
||||||
|
The input type that should be used for this value. For example, if the type is `password`, the input value will be hidden by default using a component for passwords.
|
||||||
|
|
||||||
|
- `optional?: boolean | string = false`
|
||||||
|
|
||||||
|
This property can be used to mark a wallet field as optional. If it is not set, we will assume this field is required else 'optional' will be shown to the user next to the label. You can use Markdown to customize this text.
|
||||||
|
|
||||||
|
- `help?: string | { label: string, text: string }`
|
||||||
|
|
||||||
|
If this property is set, a help icon will be shown to the user. On click, the specified text in Markdown is shown. If you additionally want to customize the icon label, you can use the object syntax.
|
||||||
|
|
||||||
|
- `editable?: boolean = true`
|
||||||
|
|
||||||
|
If this property is set to `false`, you can only configure this value once. Afterwards, it's read-only. To configure it again, you have to detach the wallet first.
|
||||||
|
|
||||||
|
- `placeholder?: string = ''`
|
||||||
|
|
||||||
|
Placeholder text to show an example value to the user before they click into the input.
|
||||||
|
|
||||||
|
- `hint?: string = ''`
|
||||||
|
|
||||||
|
If a hint is set, it will be shown below the input.
|
||||||
|
|
||||||
|
- `clear?: boolean = false`
|
||||||
|
|
||||||
|
If a button to clear the input after it has been set should be shown, set this property to `true`.
|
||||||
|
|
||||||
|
- `autoComplete?: HTMLAttribute<'autocomplete'>`
|
||||||
|
|
||||||
|
This property controls the HTML `autocomplete` attribute. See [the documentation](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete) for possible values. Not setting it usually means that the user agent can use autocompletion. This property has no effect for passwords. Autocompletion is always turned off for passwords to prevent passwords getting saved for security reasons.
|
||||||
|
|
||||||
|
#### WalletCard
|
||||||
|
|
||||||
|
- `title: string`
|
||||||
|
|
||||||
|
The card title.
|
||||||
|
|
||||||
|
- `subtitle: string`
|
||||||
|
|
||||||
|
The subtitle that is shown below the title if you enter the configuration form of a wallet.
|
||||||
|
|
||||||
|
- `badges: string[]`
|
||||||
|
|
||||||
|
The badges that are shown inside the card.
|
||||||
|
|
||||||
|
### client.js
|
||||||
|
|
||||||
|
A wallet that supports paying invoices must export the following properties in client.js which are only available if this wallet is imported on the client:
|
||||||
|
|
||||||
|
- `testConnectClient: async (config, context) => Promise<void>`
|
||||||
|
|
||||||
|
`testConnectClient` will be called during submit on the client to validate the configuration (that is passed as the first argument) more thoroughly than the initial validation by `fieldValidation`. It contains validation code that should only be called during submits instead of possibly on every change like `fieldValidation`.
|
||||||
|
|
||||||
|
How this validation is implemented depends heavily on the wallet. For example, for NWC, this function attempts to fetch the info event from the relay specified in the connection string whereas for LNbits, it makes an HTTP request to /api/v1/wallet using the given URL and API key.
|
||||||
|
|
||||||
|
This function must throw an error if the configuration was found to be invalid.
|
||||||
|
|
||||||
|
The `context` argument is an object. It makes the wallet logger for this wallet as returned by `useWalletLogger` available under `context.logger`. See [components/wallet-logger.js](../components/wallet-logger.js).
|
||||||
|
|
||||||
|
- `sendPayment: async (bolt11: string, config, context) => Promise<{ preimage: string }>`
|
||||||
|
|
||||||
|
`sendPayment` will be called if a payment is required. Therefore, this function should implement the code to pay invoices from this wallet.
|
||||||
|
|
||||||
|
The first argument is the [BOLT11 payment request](https://github.com/lightning/bolts/blob/master/11-payment-encoding.md). The `config` argument is the current configuration of this wallet (that was validated before). The `context` argument is the same as for `testConnectClient`.
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> As mentioned above, this file must exist for every wallet and at least reexport everything in index.js so make sure that the following line is included:
|
||||||
|
>
|
||||||
|
> ```js
|
||||||
|
> export * from 'wallets/<name>'
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> where `<name>` is the wallet directory name.
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> After you're done implementing the interface, you need to import this wallet in _wallets/client.js_ and add it to the array that is the default export of that file to make this wallet available across the code:
|
||||||
|
>
|
||||||
|
> ```diff
|
||||||
|
> // wallets/client.js
|
||||||
|
> import * as nwc from 'wallets/nwc/client'
|
||||||
|
> import * as lnbits from 'wallets/lnbits/client'
|
||||||
|
> import * as lnc from 'wallets/lnc/client'
|
||||||
|
> import * as lnAddr from 'wallets/lightning-address/client'
|
||||||
|
> import * as cln from 'wallets/cln/client'
|
||||||
|
> import * as lnd from 'wallets/lnd/client'
|
||||||
|
> + import * as newWallet from 'wallets/<name>/client'
|
||||||
|
>
|
||||||
|
> - export default [nwc, lnbits, lnc, lnAddr, cln, lnd]
|
||||||
|
> + export default [nwc, lnbits, lnc, lnAddr, cln, lnd, newWallet]
|
||||||
|
> ```
|
||||||
|
|
||||||
|
### server.js
|
||||||
|
|
||||||
|
A wallet that supports receiving file must export the following properties in server.js which are only available if this wallet is imported on the server:
|
||||||
|
|
||||||
|
- `testConnectServer: async (config, context) => Promise<void>`
|
||||||
|
|
||||||
|
`testConnectServer` is called on the server during submit and can thus use server dependencies like [`ln-service`](https://github.com/alexbosworth/ln-service).
|
||||||
|
|
||||||
|
It should attempt to create a test invoice to make sure that this wallet can later create invoices for receiving.
|
||||||
|
|
||||||
|
Again, like `testConnectClient`, the first argument is the wallet configuration that we should validate. However, unlike `testConnectClient`, the `context` argument here contains `me` (the user object) and `models` (the Prisma client).
|
||||||
|
|
||||||
|
- `createInvoice: async (amount: int, config, context) => Promise<bolt11: string>`
|
||||||
|
|
||||||
|
`createInvoice` will be called whenever this wallet should receive a payment. The first argument `amount` specifies the amount in satoshis. The second argument `config` is the current configuration of this wallet. The third argument `context` is the same as in `testConnectServer` except it also includes `lnd` which is the return value of [`authenticatedLndGrpc`](https://github.com/alexbosworth/ln-service?tab=readme-ov-file#authenticatedlndgrpc) using the SN node credentials.
|
||||||
|
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> Don't forget to include the following line:
|
||||||
|
>
|
||||||
|
> ```js
|
||||||
|
> export * from 'wallets/<name>'
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> where `<name>` is the wallet directory name.
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> After you're done implementing the interface, you need to import this wallet in _wallets/server.js_ and add it to the array that is the default export of that file to make this wallet available across the code:
|
||||||
|
>
|
||||||
|
> ```diff
|
||||||
|
> // wallets/server.js
|
||||||
|
> import * as lnd from 'wallets/lnd/server'
|
||||||
|
> import * as cln from 'wallets/cln/server'
|
||||||
|
> import * as lnAddr from 'wallets/lightning-address/server'
|
||||||
|
> + import * as newWallet from 'wallets/<name>/client'
|
||||||
|
>
|
||||||
|
> - export default [lnd, cln, lnAddr]
|
||||||
|
> + export default [lnd, cln, lnAddr, newWallet]
|
||||||
|
> ```
|
8
wallets/client.js
Normal file
8
wallets/client.js
Normal file
@ -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]
|
25
wallets/cln/ATTACH.md
Normal file
25
wallets/cln/ATTACH.md
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
For testing cln as an attached receiving wallet, you'll need a rune and the cert.
|
||||||
|
|
||||||
|
# host and port
|
||||||
|
|
||||||
|
`stacker_cln:3010`
|
||||||
|
|
||||||
|
# create rune
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sndev stacker_clncli --regtest createrune restrictions='["method=invoice"]'
|
||||||
|
```
|
||||||
|
|
||||||
|
# get cert
|
||||||
|
|
||||||
|
This is static in dev env so you can use this one:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tDQpNSUlCY2pDQ0FSaWdBd0lCQWdJSkFOclN2UFovWTNLRU1Bb0dDQ3FHU000OUJBTUNNQll4RkRBU0JnTlZCQU1NDQpDMk5zYmlCU2IyOTBJRU5CTUNBWERUYzFNREV3TVRBd01EQXdNRm9ZRHpRd09UWXdNVEF4TURBd01EQXdXakFXDQpNUlF3RWdZRFZRUUREQXRqYkc0Z1VtOXZkQ0JEUVRCWk1CTUdCeXFHU000OUFnRUdDQ3FHU000OUF3RUhBMElBDQpCQmptYUh1dWxjZ3dTR09ubExBSFlRbFBTUXdHWEROSld5ZnpWclY5aFRGYUJSZFFrMVl1Y3VqVFE5QXFybkVJDQpyRmR6MS9PeisyWFhENmdBMnhPbmIrNmpUVEJMTUJrR0ExVWRFUVFTTUJDQ0EyTnNib0lKYkc5allXeG9iM04wDQpNQjBHQTFVZERnUVdCQlNFY21OLzlyelMyaFI2RzdFSWdzWCs1MU4wQ2pBUEJnTlZIUk1CQWY4RUJUQURBUUgvDQpNQW9HQ0NxR1NNNDlCQU1DQTBnQU1FVUNJSENlUHZOU3Z5aUJZYXdxS2dRcXV3OUoyV1Z5SnhuMk1JWUlxejlTDQpRTDE4QWlFQWg4QlZEejhwWDdOc2xsOHNiMGJPMFJaNDljdnFRb2NDZ1ZhYnFKdVN1aWs9DQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tDQo=
|
||||||
|
```
|
||||||
|
|
||||||
|
Which is generated with the following command
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openssl base64 -A -in docker/cln/ca.pem
|
||||||
|
```
|
1
wallets/cln/client.js
Normal file
1
wallets/cln/client.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from 'wallets/cln'
|
46
wallets/cln/index.js
Normal file
46
wallets/cln/index.js
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { CLNAutowithdrawSchema } from '@/lib/validate'
|
||||||
|
|
||||||
|
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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export const card = {
|
||||||
|
title: 'CLN',
|
||||||
|
subtitle: 'autowithdraw to your Core Lightning node via [CLNRest](https://docs.corelightning.org/docs/rest)',
|
||||||
|
badges: ['receive only']
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fieldValidation = CLNAutowithdrawSchema
|
||||||
|
|
||||||
|
export const walletType = 'CLN'
|
||||||
|
|
||||||
|
export const walletField = 'walletCLN'
|
40
wallets/cln/server.js
Normal file
40
wallets/cln/server.js
Normal file
@ -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 testConnectServer = 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
|
||||||
|
}
|
321
wallets/index.js
Normal file
321
wallets/index.js
Normal file
@ -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 {
|
||||||
|
// testConnectClient should log custom INFO and OK message
|
||||||
|
// testConnectClient is optional since validation might happen during save on server
|
||||||
|
// TODO: add timeout
|
||||||
|
const validConfig = await wallet.testConnectClient?.(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))
|
||||||
|
}
|
3
wallets/lightning-address/ATTACH.md
Normal file
3
wallets/lightning-address/ATTACH.md
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
For testing lightning address autowithdraw, you'll need to reference a host reachable by the worker, e.g. `app:3000`.
|
||||||
|
|
||||||
|
You'll want to deposit in another nym's account using an address like: `nym@app:3000`.
|
1
wallets/lightning-address/client.js
Normal file
1
wallets/lightning-address/client.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from 'wallets/lightning-address'
|
25
wallets/lightning-address/index.js
Normal file
25
wallets/lightning-address/index.js
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { lnAddrAutowithdrawSchema } from '@/lib/validate'
|
||||||
|
|
||||||
|
export const name = 'lightning-address'
|
||||||
|
export const shortName = 'lnAddr'
|
||||||
|
|
||||||
|
export const fields = [
|
||||||
|
{
|
||||||
|
name: 'address',
|
||||||
|
label: 'lightning address',
|
||||||
|
type: 'text',
|
||||||
|
autoComplete: 'off'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export const card = {
|
||||||
|
title: 'lightning address',
|
||||||
|
subtitle: 'autowithdraw to a lightning address',
|
||||||
|
badges: ['receive only']
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fieldValidation = lnAddrAutowithdrawSchema
|
||||||
|
|
||||||
|
export const walletType = 'LIGHTNING_ADDRESS'
|
||||||
|
|
||||||
|
export const walletField = 'walletLightningAddress'
|
28
wallets/lightning-address/server.js
Normal file
28
wallets/lightning-address/server.js
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { addWalletLog, fetchLnAddrInvoice } from '@/api/resolvers/wallet'
|
||||||
|
import { lnAddrOptions } from '@/lib/lnurl'
|
||||||
|
|
||||||
|
export * from 'wallets/lightning-address'
|
||||||
|
|
||||||
|
export const testConnectServer = async (
|
||||||
|
{ address },
|
||||||
|
{ me, models }
|
||||||
|
) => {
|
||||||
|
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
|
||||||
|
}
|
80
wallets/lnbits/client.js
Normal file
80
wallets/lnbits/client.js
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
export * from 'wallets/lnbits'
|
||||||
|
|
||||||
|
export async function testConnectClient ({ 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
|
||||||
|
}
|
24
wallets/lnbits/index.js
Normal file
24
wallets/lnbits/index.js
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { lnbitsSchema } from '@/lib/validate'
|
||||||
|
|
||||||
|
export const name = 'lnbits'
|
||||||
|
|
||||||
|
export const fields = [
|
||||||
|
{
|
||||||
|
name: 'url',
|
||||||
|
label: 'lnbits url',
|
||||||
|
type: 'text'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'adminKey',
|
||||||
|
label: 'admin key',
|
||||||
|
type: 'password'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export const card = {
|
||||||
|
title: 'LNbits',
|
||||||
|
subtitle: 'use [LNbits](https://lnbits.com/) for payments',
|
||||||
|
badges: ['send only']
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fieldValidation = lnbitsSchema
|
35
wallets/lnc/ATTACH.md
Normal file
35
wallets/lnc/ATTACH.md
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
For testing litd as an attached receiving wallet, you'll need a pairing phrase:
|
||||||
|
|
||||||
|
This can be done one of two ways:
|
||||||
|
|
||||||
|
# cli
|
||||||
|
|
||||||
|
We only need permissions for the uri `/lnrpc.Lightning/SendPaymentSync`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ sndev stacker_litcli accounts create --balance <budget>
|
||||||
|
```
|
||||||
|
|
||||||
|
Grab the `account.id` from the output and use it here:
|
||||||
|
```bash
|
||||||
|
$ sndev stacker_litcli sessions add --type custom --label <your label> --account_id <account_id> --uri /lnrpc.Lightning/SendPaymentSync
|
||||||
|
```
|
||||||
|
|
||||||
|
Grab the `pairing_secret_mnemonic` from the output and that's your pairing phrase.
|
||||||
|
|
||||||
|
# gui
|
||||||
|
|
||||||
|
To open the gui, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sndev open litd
|
||||||
|
```
|
||||||
|
|
||||||
|
Or navigate to `http://localhost:8443` in your browser.
|
||||||
|
|
||||||
|
1. If it's not open click on the hamburger menu in the top left.
|
||||||
|
2. Click `Lightning Node Connect`
|
||||||
|
3. Click on `Create a new session`, give it a label, select `Custom` in perimissions, and click `Submit`.
|
||||||
|
4. Select `Custodial Account`, fill in the balance, and click `Submit`.
|
||||||
|
5. Copy using the copy icon in the bottom left of the session card.
|
||||||
|
|
161
wallets/lnc/client.js
Normal file
161
wallets/lnc/client.js
Normal file
@ -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 testConnectClient (credentials, { 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, { 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 = {}
|
||||||
|
}
|
||||||
|
}
|
39
wallets/lnc/index.js
Normal file
39
wallets/lnc/index.js
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { lncSchema } from '@/lib/validate'
|
||||||
|
|
||||||
|
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.',
|
||||||
|
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', 'budgetable']
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fieldValidation = lncSchema
|
25
wallets/lnd/ATTACH.md
Normal file
25
wallets/lnd/ATTACH.md
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
For testing lnd as an attached receiving wallet, you'll need a macaroon and the cert.
|
||||||
|
|
||||||
|
# host and port
|
||||||
|
|
||||||
|
`stacker_lnd:10009`
|
||||||
|
|
||||||
|
# generate macaroon
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sndev stacker_lndcli -n regtest bakemacaroon invoices:write invoices:read
|
||||||
|
```
|
||||||
|
|
||||||
|
# get cert
|
||||||
|
|
||||||
|
This is static in dev env so you can use this one:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNSekNDQWUyZ0F3SUJBZ0lRYzA2dldJQnVQOXVLZVFOSEtiRmxsREFLQmdncWhrak9QUVFEQWpBNE1SOHcKSFFZRFZRUUtFeFpzYm1RZ1lYVjBiMmRsYm1WeVlYUmxaQ0JqWlhKME1SVXdFd1lEVlFRREV3dzRZMk00TkRGawpNalkyTXpnd0hoY05NalF3TXpBM01UY3dNakU1V2hjTk1qVXdOVEF5TVRjd01qRTVXakE0TVI4d0hRWURWUVFLCkV4WnNibVFnWVhWMGIyZGxibVZ5WVhSbFpDQmpaWEowTVJVd0V3WURWUVFERXd3NFkyTTROREZrTWpZMk16Z3cKV1RBVEJnY3Foa2pPUFFJQkJnZ3Foa2pPUFFNQkJ3TkNBQVFUL253dk1IYVZDZmRWYWVJZ3Y4TUtTK1NIQVM5YwpFbGlmN1hxYTdxc1Z2UGlXN1ZuaDRNRFZFQmxNNXJnMG5rYUg2VjE3c0NDM3JzZS9PcVBMZlZZMW80SFlNSUhWCk1BNEdBMVVkRHdFQi93UUVBd0lDcERBVEJnTlZIU1VFRERBS0JnZ3JCZ0VGQlFjREFUQVBCZ05WSFJNQkFmOEUKQlRBREFRSC9NQjBHQTFVZERnUVdCQlFtYW1Wbi9LY1JxSG9OUjlkazlDMWcyTStqU1RCK0JnTlZIUkVFZHpCMQpnZ3c0WTJNNE5ERmtNalkyTXppQ0NXeHZZMkZzYUc5emRJSUxjM1JoWTJ0bGNsOXNibVNDRkdodmMzUXVaRzlqCmEyVnlMbWx1ZEdWeWJtRnNnZ1IxYm1sNGdncDFibWw0Y0dGamEyVjBnZ2RpZFdaamIyNXVod1IvQUFBQmh4QUEKQUFBQUFBQUFBQUFBQUFBQUFBQUJod1NzR3dBR01Bb0dDQ3FHU000OUJBTUNBMGdBTUVVQ0lGRDI3M1dCY01LegpVUG9PTDhid3ExNUpYdHJTR2VQS3BBZU4xVGJsWTRRNUFpRUF2S3R1aytzc3g5V1FGWkJFaVd4Q1NqVzVnZUtrCjZIQjdUZHhzVStaYmZMZz0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo=
|
||||||
|
```
|
||||||
|
|
||||||
|
Which is generated with the following command
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openssl base64 -A -in docker/lnd/stacker/tls.cert
|
||||||
|
```
|
1
wallets/lnd/client.js
Normal file
1
wallets/lnd/client.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from 'wallets/lnd'
|
47
wallets/lnd/index.js
Normal file
47
wallets/lnd/index.js
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { LNDAutowithdrawSchema } from '@/lib/validate'
|
||||||
|
|
||||||
|
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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export const card = {
|
||||||
|
title: 'LND',
|
||||||
|
subtitle: 'autowithdraw to your Lightning Labs node',
|
||||||
|
badges: ['receive only']
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fieldValidation = LNDAutowithdrawSchema
|
||||||
|
|
||||||
|
export const walletType = 'LND'
|
||||||
|
|
||||||
|
export const walletField = 'walletLND'
|
59
wallets/lnd/server.js
Normal file
59
wallets/lnd/server.js
Normal file
@ -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 testConnectServer = 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
|
||||||
|
}
|
7
wallets/nwc/ATTACH.md
Normal file
7
wallets/nwc/ATTACH.md
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
The nwc string is printed in the nwc container logs on startup ...
|
||||||
|
|
||||||
|
Open the nwc container logs like this:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ sndev logs nwc
|
||||||
|
```
|
118
wallets/nwc/client.js
Normal file
118
wallets/nwc/client.js
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
import { parseNwcUrl } from '@/lib/url'
|
||||||
|
import { Relay, finalizeEvent, nip04 } from 'nostr-tools'
|
||||||
|
|
||||||
|
export * from 'wallets/nwc'
|
||||||
|
|
||||||
|
export async function testConnectClient ({ 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}`)
|
||||||
|
}
|
||||||
|
}
|
19
wallets/nwc/index.js
Normal file
19
wallets/nwc/index.js
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { nwcSchema } from '@/lib/validate'
|
||||||
|
|
||||||
|
export const name = 'nwc'
|
||||||
|
|
||||||
|
export const fields = [
|
||||||
|
{
|
||||||
|
name: 'nwcUrl',
|
||||||
|
label: 'connection',
|
||||||
|
type: 'password'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export const card = {
|
||||||
|
title: 'NWC',
|
||||||
|
subtitle: 'use Nostr Wallet Connect for payments',
|
||||||
|
badges: ['send only']
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fieldValidation = nwcSchema
|
3
wallets/package.json
Normal file
3
wallets/package.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"type": "module"
|
||||||
|
}
|
5
wallets/server.js
Normal file
5
wallets/server.js
Normal file
@ -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(
|
||||||
|
{ walletType, walletField, createInvoice },
|
||||||
{ amount, maxFee },
|
{ amount, maxFee },
|
||||||
{ models, me: user, lnd })
|
{ me: user, models, lnd }
|
||||||
} else if (wallet.type === Wallet.CLN.type) {
|
)
|
||||||
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…
x
Reference in New Issue
Block a user