Wallet definitions with uniform interface (#1243)
* wip: Use uniform interface for wallets * Fix import error * Update wallet logging + other stuff * add canPay and canSend to wallet definition * rename 'default payment method' to 'enabled' and add enable + disable method * Set canPay, canReceive in useWallet * Enable wallet if just configured * Don't pass logger to sendPayment * Add logging to attach & detach * Add schema to wallet def * Add NWC wallet * Fix unused isDefault saved in config * Fix enableWallet * wrong storage key was used * broke if wallets with no configs existed * Run validation during save * Use INFO level for 'wallet disabled' message * Pass config with spread operator * Support help, optional, hint in wallet fields * wip: Add LNC * Fix 20s page load for /settings/wallets.json?nodata=true For some reason, if nodata is passed (which is the case if going back), the page takes 20s to load. * Fix extremely slow page load for LNC import I noticed that the combination of ``` import { Form, PasswordInput, SubmitButton } from '@/components/form' ``` in components/wallet/lnc.js and the dynamic import via `await import` in components/wallet/index.js caused extremely slow page loads. * Use normal imports * Revert "Fix 20s page load for /settings/wallets.json?nodata=true" This reverts commit deb476b3a966569fefcfdf4082d6b64f90fbd0a2. Not using the dynamic import for LNC fixed the slow page load with ?nodata=true. * Remove follow and show recent logs first * Fix position of log start marker * Add FIXMEs for LNC I can't get LNC to connect. It just hangs forever on lnc.connect(). See FIXMEs. * Remove logger.error since already handled in useWallet * Don't require destructuring to pass props to input * wip: Add LND autowithdrawals * receiving wallets need to export 'server' object field * don't print macaroon error stack * fix missing wallet logs order update * mark autowithdrawl settings as required * fix server wallet logs deletion * remove canPay and canReceive since it was confusing where it is available TODO * also use numeric priority for sending wallets to be consistent with how status for receiving wallets is determined * define createInvoice function in wallet definition * consistent wallet logs: sending wallets use 'wallet attached'+'wallet enabled/disabled' whereas receiving wallets use 'wallet created/updated' * see FIXMEs * Fix TypeError * Fix sendPayment called with empty config * removed useEffect such that config is available on first render * fix hydration error using dynamic import without SSR * Fix confusing UX around enabled * Remove FIXMEs Rebase on master seemed to have fixed these, weird * Use same error format in toast and wallet log * Fix usage of conditional hooks in useConfig * Fix isConfigured * Fix delete wallet logs on server * Fix wallet logs refetch onError does not exist on client.mutate * Fix TypeError in isConfigured if no enabled wallet found * Only include local/server config if required * Fix another hydration error * Fix server config not updated after save or detach * Also use 'enabled' for server wallets * Fix wallet logs not updated after server delete * Consistent logs between local and server wallets * 'wallet attached' on create * 'wallet updated' on config updates * 'wallet enabled' and 'wallet disabled' if checkbox changed * 'wallet detached' on delete * Also enable server wallets on create * Disable checkbox if not configured yet * Move all validation schema into lib/validate * Implement drag & drop w/o persistence * Use dynamic import for WalletCard This fixes a lot of issues with hydration * Save order as priority * Fix autowithdrawSettings not applied Form requires config in flat format but mutation requires autowithdraw settings in a separate 'settings' field. I have decided that config will be in flat form format. It will be transformed into mutation format during save. * Save dedicated enabled flag for server wallets * wallet table now contains boolean column 'enabled' * 'priority' is now a number everywhere * use consistent order between how autowithdrawals are attempted and server wallets cards * Fix onCanceled missing * Fix typo * Fix noisy changes in lib/validate I moved the schema for lnbits, nwc and lnc out of lib/validate only to put them back in there later. This commit should make the changeset cleaner by removing noise. * Split arguments into [value,] config, context * Run lnbits url.replace in validate and sendPayment * Remove unnecessary WALLETS_QUERY * Generate wallet mutation from fields * Generate wallet resolver from fields * Fix import inconsistency between app and worker * Use wallet.createInvoice for autowithdrawals * Fix success autowithdrawal log * Fix wallet security banner shown for server wallets * Add autowithdrawal to lightning address * Add optional wallet short name for logging * Fix draggable * Fix autowithdraw loop * Add missing hints * Add CLN autowithdrawal * Detach wallets and delete logs on logout * Remove Wallet in lib/constants * Use inject function for resolvers and typeDefs * Fix priority ignored when fetching enabled wallet * Fix draggable false on first page load due to SSR * Use touches instead of dnd on mobile Browsers don't support drag events for touch devices. To have a consistent implementation for desktop and mobile, we would need to use mousedown/touchstart, mouseup/touchend and mousemove/touchmove. For now, this commit makes changing the order possible on touch devices with simple touches. * Fix duplicate CLN error * Fix autowithdraw priority order * Fix error per invalid bip39 word * Update LNC code * remove LNC FIXMEs Mhh, I guess the TURN server was down or something? It now magically works. Or maybe it only works once per mnemonic? * also removed the lnc.lnd.lightning.getInfo() call since we don't ask and need permission for this RPC for payments. * setting a password does not work though. It fails with 'The password provided is not valid' which is triggered at https://github.com/lightninglabs/lnc-web/blob/main/lib/util/credentialStore.ts#L81. * Fix order if wallet with no priority exists * Use common sort * Add link to lnbits.com * Add example wallet def * Remove TODOs TODO in components/wallet-logger.js was handled. I don't see a need for the TODO in lib/wallet.js anymore. This function will only be called with the wallet of type LIGHTNING_ADDRESS anyway. * Remove console.log * Toast priority save errors * Fix leaking relay connections * Remove 'tor or clearnet' hint for LN addresses * Remove React dependency from wallet definitions * Generate resolver name from walletField * Move wallets into top level directory wallet/ * Put wallets into own folder * Fix generateMutation * remove resolverName property from wallet defs * move function into lib/wallet * use function in generateMutation on client to fix wrongly generated mutation * Separate client and server imports by files * wallets now consist of an index.js, a client.js and a server.js file * client.js is imported on the client and contains the client portion * server.js is imported on the server and contains the server porition * both reexport index.js so everything in index.js can be shared by client and server * every wallet contains a client.js file since they are all imported on the client to show the cards * client.js of every wallet is reexported as an array in wallets/client.js * server.js of every wallet is reexported as an array in wallets/server.js FIXME: for some reason, worker does not properly import the default export of wallets/server.js * Fix worker import of wallets/server * Fix wallet.server usage * I removed wallet.server in a previous commit * the client couldn't determine which wallet was stored on the server since all server specific fields were set in server.js * walletType and walletField are now set in index.js * walletType is now used to determine if a wallet is stored on the server * also included some formatting changes * Fix w.default usage Since package.json with { "type": "module" } was added, this is no longer needed. * Fix id access in walletPrioritySort * Fix autowithdrawal error log * Generate validation schema for LNbits * Generate validation schema for NWC * Rename to torAllowed * Generate validation schema for LNC * Generate validation schema for LND * Generate validation schema for LnAddr * Remove stringTypes * Generate validation schema for CLN * Make clear that message belongs to test * validate.message was used in tandem with validate.test * it might be confused as the message if the validation for validate.type failed * now validate.test can be a function or an object of { test, message } shape which matches Yup.test * Remove validate.schema as a trap door * make lnc work * Return null if no wallet was found * Revert code around schema generation * Transform autowithdrawSchemaMembers into an object * Rename schema to yupSchema * Fix missing required for LNbits adminKey * Support formik form-level validation * Fix missing addWalletLog import * Fix missing space after = * fix merge conflict resolution mistake * remove non-custodial* badges * create guides for attaching wallets in sndev * Use built-in formik validation or Yup schema but not both * Rename: validate -> testConnectClient, testConnect -> testConnectServer * make lnaddr autowithdraw work in dev * move ATTACH docs to ./wallets and add lnaddr doc * Fix missing rename: yupSchema -> fieldValidation * Remove unused context * Add documentation how to add wallets --------- Co-authored-by: keyan <keyan.kousha+huumn@gmail.com> Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
This commit is contained in:
parent
cadfc47eb5
commit
371e7417ce
|
@ -1,5 +1,6 @@
|
|||
PRISMA_SLOW_LOGS_MS=
|
||||
GRAPHQL_SLOW_LOGS_MS=
|
||||
NODE_ENV=development
|
||||
|
||||
############################################################################
|
||||
# OPTIONAL SECRETS #
|
||||
|
|
|
@ -20,7 +20,6 @@ node_modules/
|
|||
.DS_Store
|
||||
*.pem
|
||||
/*.sql
|
||||
lnbits/
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
|
|
|
@ -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 crypto, { timingSafeEqual } from 'crypto'
|
||||
import serialize from './serial'
|
||||
import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
|
||||
import { SELECT, itemQueryWithMeta } from './item'
|
||||
import { lnAddrOptions } from '@/lib/lnurl'
|
||||
import { msatsToSats, msatsToSatsDecimal, ensureB64 } from '@/lib/format'
|
||||
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, Wallet } from '@/lib/constants'
|
||||
import { msatsToSats, msatsToSatsDecimal } from '@/lib/format'
|
||||
import { amountSchema, ssValidate, withdrawlSchema, lnAddrSchema, formikValidate } 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 { datePivot } from '@/lib/time'
|
||||
import assertGofacYourself from './ofac'
|
||||
import assertApiKeyNotPermitted from './apiKey'
|
||||
import { createInvoice as createInvoiceCLN } from '@/lib/cln'
|
||||
import { bolt11Tags } from '@/lib/bolt11'
|
||||
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 }) {
|
||||
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')
|
||||
}
|
||||
|
||||
export default {
|
||||
const resolvers = {
|
||||
Query: {
|
||||
invoice: getInvoice,
|
||||
wallet: async (parent, { id }, { me, models }) => {
|
||||
|
@ -318,9 +344,10 @@ export default {
|
|||
where: {
|
||||
userId: me.id
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'asc'
|
||||
}
|
||||
orderBy: [
|
||||
{ createdAt: 'desc' },
|
||||
{ id: 'desc' }
|
||||
]
|
||||
})
|
||||
}
|
||||
},
|
||||
|
@ -423,85 +450,6 @@ export default {
|
|||
}
|
||||
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 }) => {
|
||||
if (!me) {
|
||||
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
|
||||
|
@ -514,7 +462,7 @@ export default {
|
|||
|
||||
await models.$transaction([
|
||||
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
|
||||
|
@ -598,6 +546,8 @@ export default {
|
|||
}
|
||||
}
|
||||
|
||||
export default injectResolvers(resolvers)
|
||||
|
||||
export const addWalletLog = async ({ wallet, level, message }, { me, models }) => {
|
||||
try {
|
||||
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 (
|
||||
{ schema, wallet, testConnect }, { settings, data }, { me, models }) {
|
||||
{ schema, formikValidate: validate, wallet, testConnectServer }, { settings, data }, { me, models }) {
|
||||
if (!me) {
|
||||
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
|
||||
}
|
||||
assertApiKeyNotPermitted({ me })
|
||||
|
||||
if (schema) {
|
||||
await ssValidate(schema, { ...data, ...settings }, { me, models })
|
||||
}
|
||||
if (validate) {
|
||||
await formikValidate(validate, { ...data, ...settings })
|
||||
}
|
||||
|
||||
if (testConnect) {
|
||||
if (testConnectServer) {
|
||||
try {
|
||||
await testConnect(data)
|
||||
await testConnectServer(data)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
await addWalletLog({ wallet, level: 'ERROR', message: 'failed to attach wallet' }, { me, models })
|
||||
throw new GraphQLError('failed to connect to wallet', { extensions: { code: 'BAD_INPUT' } })
|
||||
const message = err.message || err.toString?.()
|
||||
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 { autoWithdrawThreshold, autoWithdrawMaxFeePercent, priority } = settings
|
||||
const { autoWithdrawThreshold, autoWithdrawMaxFeePercent, enabled, priority } = settings
|
||||
|
||||
const txs = [
|
||||
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) {
|
||||
txs.push(
|
||||
models.wallet.update({
|
||||
where: { id: Number(id), userId: me.id },
|
||||
data: {
|
||||
priority: priority ? 1 : 0,
|
||||
enabled,
|
||||
priority,
|
||||
[wallet.field]: {
|
||||
update: {
|
||||
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 {
|
||||
txs.push(
|
||||
models.wallet.create({
|
||||
data: {
|
||||
priority: Number(priority),
|
||||
enabled,
|
||||
priority,
|
||||
userId: me.id,
|
||||
type: wallet.type,
|
||||
[wallet.field]: {
|
||||
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)
|
||||
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 },
|
||||
{ me, models, lnd, headers, walletId }) {
|
||||
{ me, models, lnd, headers }) {
|
||||
if (!me) {
|
||||
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
|
||||
}
|
||||
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)
|
||||
await ssValidate(lnAddrSchema, { addr, amount, maxFee, comment, ...payer }, options)
|
||||
|
||||
|
@ -788,10 +767,10 @@ export async function sendToLnAddr (parent, { addr, amount, maxFee, comment, ...
|
|||
try {
|
||||
const decoded = await decodePaymentRequest({ lnd, request: res.pr })
|
||||
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
|
||||
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')
|
||||
}
|
||||
|
@ -803,6 +782,5 @@ export async function sendToLnAddr (parent, { addr, amount, maxFee, comment, ...
|
|||
throw e
|
||||
}
|
||||
|
||||
// take pr and createWithdrawl
|
||||
return await createWithdrawal(parent, { invoice: res.pr, maxFee }, { me, models, lnd, headers, walletId })
|
||||
return res
|
||||
}
|
||||
|
|
|
@ -1,6 +1,32 @@
|
|||
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 {
|
||||
invoice(id: ID!): Invoice!
|
||||
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!
|
||||
cancelInvoice(hash: String!, hmac: String!): Invoice!
|
||||
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
|
||||
deleteWalletLogs(wallet: String): Boolean
|
||||
}
|
||||
|
@ -30,7 +53,8 @@ export default gql`
|
|||
id: ID!
|
||||
createdAt: Date!
|
||||
type: String!
|
||||
priority: Boolean!
|
||||
enabled: Boolean!
|
||||
priority: Int!
|
||||
wallet: WalletDetails!
|
||||
}
|
||||
|
||||
|
@ -55,7 +79,8 @@ export default gql`
|
|||
input AutowithdrawSettings {
|
||||
autoWithdrawThreshold: Int!
|
||||
autoWithdrawMaxFeePercent: Float!
|
||||
priority: Boolean!
|
||||
priority: Int
|
||||
enabled: Boolean
|
||||
}
|
||||
|
||||
type Invoice {
|
||||
|
@ -123,3 +148,5 @@ export default gql`
|
|||
message: String!
|
||||
}
|
||||
`
|
||||
|
||||
export default gql`${injectTypeDefs(typeDefs)}`
|
||||
|
|
|
@ -8,15 +8,14 @@ function autoWithdrawThreshold ({ me }) {
|
|||
return isNumber(me?.privates?.autoWithdrawThreshold) ? me?.privates?.autoWithdrawThreshold : 10000
|
||||
}
|
||||
|
||||
export function autowithdrawInitial ({ me, priority = false }) {
|
||||
export function autowithdrawInitial ({ me }) {
|
||||
return {
|
||||
priority,
|
||||
autoWithdrawThreshold: autoWithdrawThreshold({ me }),
|
||||
autoWithdrawMaxFeePercent: isNumber(me?.privates?.autoWithdrawMaxFeePercent) ? me?.privates?.autoWithdrawMaxFeePercent : 1
|
||||
}
|
||||
}
|
||||
|
||||
export function AutowithdrawSettings ({ priority }) {
|
||||
export function AutowithdrawSettings ({ wallet }) {
|
||||
const me = useMe()
|
||||
const threshold = autoWithdrawThreshold({ me })
|
||||
|
||||
|
@ -29,9 +28,10 @@ export function AutowithdrawSettings ({ priority }) {
|
|||
return (
|
||||
<>
|
||||
<Checkbox
|
||||
label='make default autowithdraw method'
|
||||
id='priority'
|
||||
name='priority'
|
||||
disabled={!wallet.isConfigured}
|
||||
label='enabled'
|
||||
id='enabled'
|
||||
name='enabled'
|
||||
/>
|
||||
<div className='my-4 border border-3 rounded'>
|
||||
<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}
|
||||
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label='max fee'
|
||||
name='autoWithdrawMaxFeePercent'
|
||||
hint='max fee as percent of withdrawal amount'
|
||||
append={<InputGroup.Text>%</InputGroup.Text>}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -802,7 +802,7 @@ export function CheckboxGroup ({ label, groupClassName, children, ...props }) {
|
|||
const StorageKeyPrefixContext = createContext()
|
||||
|
||||
export function Form ({
|
||||
initial, schema, onSubmit, children, initialError, validateImmediately,
|
||||
initial, validate, schema, onSubmit, children, initialError, validateImmediately,
|
||||
storageKeyPrefix, validateOnChange = true, requireSession, innerRef,
|
||||
...props
|
||||
}) {
|
||||
|
@ -856,6 +856,7 @@ export function Form ({
|
|||
<Formik
|
||||
initialValues={initial}
|
||||
validateOnChange={validateOnChange}
|
||||
validate={validate}
|
||||
validationSchema={schema}
|
||||
initialTouched={validateImmediately && initial}
|
||||
validateOnBlur={false}
|
||||
|
|
|
@ -8,17 +8,13 @@ import Bolt11Info from './bolt11-info'
|
|||
import { useQuery } from '@apollo/client'
|
||||
import { INVOICE } from '@/fragments/wallet'
|
||||
import { FAST_POLL_INTERVAL, SSR } from '@/lib/constants'
|
||||
import { WebLnNotEnabledError } from './payment'
|
||||
import { NoAttachedWalletError } from './payment'
|
||||
import ItemJob from './item-job'
|
||||
import Item from './item'
|
||||
import { CommentFlat } from './comment'
|
||||
import classNames from 'classnames'
|
||||
|
||||
export default function Invoice ({
|
||||
id, query = INVOICE, modal, onPayment, onCanceled,
|
||||
info, successVerb, webLn = true, webLnError,
|
||||
poll, waitFor, ...props
|
||||
}) {
|
||||
export default function Invoice ({ id, query = INVOICE, modal, onPayment, onCanceled, info, successVerb, useWallet = true, walletError, poll, waitFor, ...props }) {
|
||||
const [expired, setExpired] = useState(false)
|
||||
const { data, error } = useQuery(query, SSR
|
||||
? {}
|
||||
|
@ -58,15 +54,15 @@ export default function Invoice ({
|
|||
if (invoice.cancelled) {
|
||||
variant = 'failed'
|
||||
status = 'cancelled'
|
||||
webLn = false
|
||||
useWallet = false
|
||||
} else if (invoice.confirmedAt || (invoice.isHeld && invoice.satsReceived && !expired)) {
|
||||
variant = 'confirmed'
|
||||
status = `${numWithUnits(invoice.satsReceived, { abbreviate: false })} ${successVerb || 'deposited'}`
|
||||
webLn = false
|
||||
useWallet = false
|
||||
} else if (expired) {
|
||||
variant = 'failed'
|
||||
status = 'expired'
|
||||
webLn = false
|
||||
useWallet = false
|
||||
} else if (invoice.expiresAt) {
|
||||
variant = 'pending'
|
||||
status = (
|
||||
|
@ -82,13 +78,13 @@ export default function Invoice ({
|
|||
|
||||
return (
|
||||
<>
|
||||
{webLnError && !(webLnError instanceof WebLnNotEnabledError) &&
|
||||
{walletError && !(walletError instanceof NoAttachedWalletError) &&
|
||||
<div className='text-center fw-bold text-info mb-3' style={{ lineHeight: 1.25 }}>
|
||||
Paying from attached wallet failed:
|
||||
<code> {webLnError.message}</code>
|
||||
<code> {walletError.message}</code>
|
||||
</div>}
|
||||
<Qr
|
||||
webLn={webLn} value={invoice.bolt11}
|
||||
useWallet={useWallet} value={invoice.bolt11}
|
||||
description={numWithUnits(invoice.satsRequested, { abbreviate: false })}
|
||||
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 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 = () => {
|
||||
// 100 adjectives * 100 nouns * 10000 = 100M possible names
|
||||
|
@ -44,9 +41,7 @@ export const LoggerContext = createContext()
|
|||
export const LoggerProvider = ({ children }) => {
|
||||
return (
|
||||
<ServiceWorkerLoggerProvider>
|
||||
<WalletLoggerProvider>
|
||||
{children}
|
||||
</WalletLoggerProvider>
|
||||
</ServiceWorkerLoggerProvider>
|
||||
)
|
||||
}
|
||||
|
@ -122,189 +117,3 @@ function ServiceWorkerLoggerProvider ({ children }) {
|
|||
export function useServiceWorkerLogger () {
|
||||
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 Price from '../price'
|
||||
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 NoteIcon from '../../svgs/notification-4-fill.svg'
|
||||
import { useMe } from '../me'
|
||||
|
@ -22,8 +22,7 @@ import SearchIcon from '../../svgs/search-line.svg'
|
|||
import classNames from 'classnames'
|
||||
import SnIcon from '@/svgs/sn.svg'
|
||||
import { useHasNewNotes } from '../use-has-new-notes'
|
||||
import { useWalletLogger } from '../logger'
|
||||
import { useWebLNConfigurator } from '../webln'
|
||||
import { useWallets } from 'wallets'
|
||||
|
||||
export function Brand ({ className }) {
|
||||
return (
|
||||
|
@ -257,8 +256,7 @@ export default function LoginButton ({ className }) {
|
|||
|
||||
export function LogoutDropdownItem () {
|
||||
const { registration: swRegistration, togglePushSubscription } = useServiceWorker()
|
||||
const webLN = useWebLNConfigurator()
|
||||
const { deleteLogs } = useWalletLogger()
|
||||
const wallets = useWallets()
|
||||
return (
|
||||
<Dropdown.Item
|
||||
onClick={async () => {
|
||||
|
@ -267,12 +265,9 @@ export function LogoutDropdownItem () {
|
|||
if (pushSubscription) {
|
||||
await togglePushSubscription().catch(console.error)
|
||||
}
|
||||
// detach wallets
|
||||
await webLN.clearConfig().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 wallets.resetClient().catch(console.error)
|
||||
|
||||
await signOut({ callbackUrl: '/' })
|
||||
}}
|
||||
>logout
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { useCallback, useMemo } from 'react'
|
||||
import { useMe } from './me'
|
||||
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 { INVOICE } from '@/fragments/wallet'
|
||||
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 () {
|
||||
super('no enabled WebLN provider found')
|
||||
this.name = 'WebLnNotEnabledError'
|
||||
super('no attached wallet found')
|
||||
this.name = 'NoAttachedWalletError'
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -126,19 +126,19 @@ export const useInvoice = () => {
|
|||
return { create, waitUntilPaid: waitController.wait, stopWaiting: waitController.stop, cancel }
|
||||
}
|
||||
|
||||
export const useWebLnPayment = () => {
|
||||
export const useWalletPayment = () => {
|
||||
const invoice = useInvoice()
|
||||
const provider = useWebLN()
|
||||
const wallet = useWallet()
|
||||
|
||||
const waitForWebLnPayment = useCallback(async ({ id, bolt11 }, waitFor) => {
|
||||
if (!provider) {
|
||||
throw new WebLnNotEnabledError()
|
||||
const waitForWalletPayment = useCallback(async ({ id, bolt11 }, waitFor) => {
|
||||
if (!wallet) {
|
||||
throw new NoAttachedWalletError()
|
||||
}
|
||||
try {
|
||||
return await new Promise((resolve, reject) => {
|
||||
// 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
|
||||
provider.sendPayment(bolt11)
|
||||
wallet.sendPayment(bolt11)
|
||||
// JIT invoice payments will never resolve here
|
||||
// since they only get resolved after settlement which can't happen here
|
||||
.then(resolve)
|
||||
|
@ -148,21 +148,21 @@ export const useWebLnPayment = () => {
|
|||
.catch(reject)
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('WebLN payment failed:', err)
|
||||
console.error('payment failed:', err)
|
||||
throw err
|
||||
} finally {
|
||||
invoice.stopWaiting()
|
||||
}
|
||||
}, [provider, invoice])
|
||||
}, [wallet, invoice])
|
||||
|
||||
return waitForWebLnPayment
|
||||
return waitForWalletPayment
|
||||
}
|
||||
|
||||
export const useQrPayment = () => {
|
||||
const invoice = useInvoice()
|
||||
const showModal = useShowModal()
|
||||
|
||||
const waitForQrPayment = useCallback(async (inv, webLnError,
|
||||
const waitForQrPayment = useCallback(async (inv, walletError,
|
||||
{
|
||||
keepOpen = true,
|
||||
cancelOnClose = true,
|
||||
|
@ -186,8 +186,8 @@ export const useQrPayment = () => {
|
|||
description
|
||||
status='loading'
|
||||
successVerb='received'
|
||||
webLn={false}
|
||||
webLnError={webLnError}
|
||||
useWallet={false}
|
||||
walletError={walletError}
|
||||
waitFor={waitFor}
|
||||
onCanceled={inv => { onClose(); reject(new InvoiceCanceledError(inv?.hash, inv?.actionError)) }}
|
||||
onPayment={() => { paid = true; onClose(); resolve() }}
|
||||
|
@ -204,22 +204,22 @@ export const usePayment = () => {
|
|||
const me = useMe()
|
||||
const feeButton = useFeeButton()
|
||||
const invoice = useInvoice()
|
||||
const waitForWebLnPayment = useWebLnPayment()
|
||||
const waitForWalletPayment = useWalletPayment()
|
||||
const waitForQrPayment = useQrPayment()
|
||||
|
||||
const waitForPayment = useCallback(async (invoice) => {
|
||||
let webLnError
|
||||
let walletError
|
||||
try {
|
||||
return await waitForWebLnPayment(invoice)
|
||||
return await waitForWalletPayment(invoice)
|
||||
} catch (err) {
|
||||
if (err instanceof InvoiceCanceledError || err instanceof InvoiceExpiredError) {
|
||||
// bail since qr code payment will also fail
|
||||
throw err
|
||||
}
|
||||
webLnError = err
|
||||
walletError = err
|
||||
}
|
||||
return await waitForQrPayment(invoice, webLnError)
|
||||
}, [waitForWebLnPayment, waitForQrPayment])
|
||||
return await waitForQrPayment(invoice, walletError)
|
||||
}, [waitForWalletPayment, waitForQrPayment])
|
||||
|
||||
const request = useCallback(async (amount) => {
|
||||
amount ??= feeButton?.total
|
||||
|
|
|
@ -2,25 +2,25 @@ import QRCode from 'qrcode.react'
|
|||
import { CopyInput, InputSkeleton } from './form'
|
||||
import InvoiceStatus from './invoice-status'
|
||||
import { useEffect } from 'react'
|
||||
import { useWebLN } from './webln'
|
||||
import { useWallet } from 'wallets'
|
||||
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 provider = useWebLN()
|
||||
const wallet = useWallet()
|
||||
|
||||
useEffect(() => {
|
||||
async function effect () {
|
||||
if (webLn && provider) {
|
||||
if (automated && wallet) {
|
||||
try {
|
||||
await provider.sendPayment(value)
|
||||
await wallet.sendPayment(value)
|
||||
} catch (e) {
|
||||
console.log(e?.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
effect()
|
||||
}, [provider])
|
||||
}, [wallet])
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
import { SSR } from '@/lib/constants'
|
||||
import { useCallback, useState } from 'react'
|
||||
|
||||
export default function useLocalState (storageKey, initialValue = '') {
|
||||
const [value, innerSetValue] = useState(
|
||||
initialValue ||
|
||||
(SSR ? null : JSON.parse(window.localStorage.getItem(storageKey)))
|
||||
)
|
||||
|
||||
const setValue = useCallback((newValue) => {
|
||||
window.localStorage.setItem(storageKey, JSON.stringify(newValue))
|
||||
innerSetValue(newValue)
|
||||
}, [storageKey])
|
||||
|
||||
const clearValue = useCallback(() => {
|
||||
window.localStorage.removeItem(storageKey)
|
||||
innerSetValue(null)
|
||||
}, [storageKey])
|
||||
|
||||
return [value, setValue, clearValue]
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
import { useApolloClient, useLazyQuery, useMutation } from '@apollo/client'
|
||||
import { 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'
|
||||
|
||||
/*
|
||||
|
@ -22,17 +22,17 @@ export function usePaidMutation (mutation,
|
|||
const [getPaidAction] = useLazyQuery(GET_PAID_ACTION, {
|
||||
fetchPolicy: 'network-only'
|
||||
})
|
||||
const waitForWebLnPayment = useWebLnPayment()
|
||||
const waitForWalletPayment = useWalletPayment()
|
||||
const waitForQrPayment = useQrPayment()
|
||||
const client = useApolloClient()
|
||||
// innerResult is used to store/control the result of the mutation when innerMutate runs
|
||||
const [innerResult, setInnerResult] = useState(result)
|
||||
|
||||
const waitForPayment = useCallback(async (invoice, { alwaysShowQROnFailure = false, persistOnNavigate = false, waitFor }) => {
|
||||
let webLnError
|
||||
let walletError
|
||||
const start = Date.now()
|
||||
try {
|
||||
return await waitForWebLnPayment(invoice, waitFor)
|
||||
return await waitForWalletPayment(invoice, waitFor)
|
||||
} catch (err) {
|
||||
if (
|
||||
(!alwaysShowQROnFailure && Date.now() - start > 1000) ||
|
||||
|
@ -42,10 +42,10 @@ export function usePaidMutation (mutation,
|
|||
// also bail if the payment took more than 1 second
|
||||
throw err
|
||||
}
|
||||
webLnError = err
|
||||
walletError = err
|
||||
}
|
||||
return await waitForQrPayment(invoice, webLnError, { persistOnNavigate, waitFor })
|
||||
}, [waitForWebLnPayment, waitForQrPayment])
|
||||
return await waitForQrPayment(invoice, walletError, { persistOnNavigate, waitFor })
|
||||
}, [waitForWalletPayment, waitForQrPayment])
|
||||
|
||||
const innerMutate = useCallback(async ({
|
||||
onCompleted: innerOnCompleted, ...innerOptions
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
import { Button } from 'react-bootstrap'
|
||||
import CancelButton from './cancel-button'
|
||||
import { SubmitButton } from './form'
|
||||
|
||||
export default function WalletButtonBar ({
|
||||
wallet, disable,
|
||||
className, children, onDelete, onCancel, hasCancel = true,
|
||||
createText = 'attach', deleteText = 'detach', editText = 'save'
|
||||
}) {
|
||||
return (
|
||||
<div className={`mt-3 ${className}`}>
|
||||
<div className='d-flex justify-content-between'>
|
||||
{wallet.isConfigured &&
|
||||
<Button onClick={onDelete} variant='grey-medium'>{deleteText}</Button>}
|
||||
{children}
|
||||
<div className='d-flex align-items-center ms-auto'>
|
||||
{hasCancel && <CancelButton onClick={onCancel} />}
|
||||
<SubmitButton variant='primary' disabled={disable}>{wallet.isConfigured ? editText : createText}</SubmitButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -1,18 +1,15 @@
|
|||
import { Badge, Button, Card } from 'react-bootstrap'
|
||||
import { Badge, Card } from 'react-bootstrap'
|
||||
import styles from '@/styles/wallet.module.css'
|
||||
import Plug from '@/svgs/plug.svg'
|
||||
import Gear from '@/svgs/settings-5-fill.svg'
|
||||
import Link from 'next/link'
|
||||
import CancelButton from './cancel-button'
|
||||
import { SubmitButton } from './form'
|
||||
import { Status } from './webln'
|
||||
import { Status } from 'wallets'
|
||||
|
||||
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
|
||||
switch (status) {
|
||||
switch (wallet.status) {
|
||||
case Status.Enabled:
|
||||
case true:
|
||||
indicator = styles.success
|
||||
|
@ -42,35 +39,13 @@ export function WalletCard ({ title, badges, provider, status }) {
|
|||
</Badge>)}
|
||||
</Card.Subtitle>
|
||||
</Card.Body>
|
||||
{provider &&
|
||||
<Link href={`/settings/wallets/${provider}`}>
|
||||
<Link href={`/settings/wallets/${wallet.name}`}>
|
||||
<Card.Footer className={styles.attach}>
|
||||
{configured
|
||||
{wallet.isConfigured
|
||||
? <>configure<Gear width={14} height={14} /></>
|
||||
: <>attach<Plug width={14} height={14} /></>}
|
||||
</Card.Footer>
|
||||
</Link>}
|
||||
</Link>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export function WalletButtonBar ({
|
||||
status, disable,
|
||||
className, children, onDelete, onCancel, hasCancel = true,
|
||||
createText = 'attach', deleteText = 'detach', editText = 'save'
|
||||
}) {
|
||||
const configured = isConfigured(status)
|
||||
return (
|
||||
<div className={`mt-3 ${className}`}>
|
||||
<div className='d-flex justify-content-between'>
|
||||
{configured &&
|
||||
<Button onClick={onDelete} variant='grey-medium'>{deleteText}</Button>}
|
||||
{children}
|
||||
<div className='d-flex align-items-center ms-auto'>
|
||||
{hasCancel && <CancelButton onClick={onCancel} />}
|
||||
<SubmitButton variant='primary' disabled={disable}>{configured ? editText : createText}</SubmitButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,274 @@
|
|||
import LogMessage from './log-message'
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import styles from '@/styles/log.module.css'
|
||||
import { Button } from 'react-bootstrap'
|
||||
import { useToast } from './toast'
|
||||
import { useShowModal } from './modal'
|
||||
import { WALLET_LOGS } from '@/fragments/wallet'
|
||||
import { getWalletByType } from 'wallets'
|
||||
import { gql, useMutation, useQuery } from '@apollo/client'
|
||||
import { useMe } from './me'
|
||||
|
||||
export function WalletLogs ({ wallet, embedded }) {
|
||||
const logs = useWalletLogs(wallet)
|
||||
|
||||
const tableRef = useRef()
|
||||
const showModal = useShowModal()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='d-flex w-100 align-items-center mb-3'>
|
||||
<span
|
||||
style={{ cursor: 'pointer' }}
|
||||
className='text-muted fw-bold nav-link ms-auto' onClick={() => {
|
||||
showModal(onClose => <DeleteWalletLogsObstacle wallet={wallet} onClose={onClose} />)
|
||||
}}
|
||||
>clear logs
|
||||
</span>
|
||||
</div>
|
||||
<div ref={tableRef} className={`${styles.logTable} ${embedded ? styles.embedded : ''}`}>
|
||||
{logs.length === 0 && <div className='w-100 text-center'>empty</div>}
|
||||
<table>
|
||||
<tbody>
|
||||
{logs.map((log, i) => <LogMessage key={i} {...log} />)}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className='w-100 text-center'>------ start of logs ------</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function DeleteWalletLogsObstacle ({ wallet, onClose }) {
|
||||
const toaster = useToast()
|
||||
const { deleteLogs } = useWalletLogger(wallet)
|
||||
|
||||
const prompt = `Do you really want to delete all ${wallet ? '' : 'wallet'} logs ${wallet ? 'of this wallet' : ''}?`
|
||||
return (
|
||||
<div className='text-center'>
|
||||
{prompt}
|
||||
<div className='d-flex justify-center align-items-center mt-3 mx-auto'>
|
||||
<span style={{ cursor: 'pointer' }} className='d-flex ms-auto text-muted fw-bold nav-link mx-3' onClick={onClose}>cancel</span>
|
||||
<Button
|
||||
className='d-flex me-auto mx-3' variant='danger'
|
||||
onClick={
|
||||
async () => {
|
||||
try {
|
||||
await deleteLogs()
|
||||
onClose()
|
||||
toaster.success('deleted wallet logs')
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
toaster.danger('failed to delete wallet logs')
|
||||
}
|
||||
}
|
||||
}
|
||||
>delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const WalletLoggerContext = createContext()
|
||||
const WalletLogsContext = createContext()
|
||||
|
||||
const initIndexedDB = async (dbName, storeName) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!window.indexedDB) {
|
||||
return reject(new Error('IndexedDB not supported'))
|
||||
}
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API/Using_IndexedDB
|
||||
const request = window.indexedDB.open(dbName, 1)
|
||||
|
||||
let db
|
||||
request.onupgradeneeded = () => {
|
||||
// this only runs if version was changed during open
|
||||
db = request.result
|
||||
if (!db.objectStoreNames.contains(storeName)) {
|
||||
const objectStore = db.createObjectStore(storeName, { autoIncrement: true })
|
||||
objectStore.createIndex('ts', 'ts')
|
||||
objectStore.createIndex('wallet_ts', ['wallet', 'ts'])
|
||||
}
|
||||
}
|
||||
|
||||
request.onsuccess = () => {
|
||||
// this gets called after onupgradeneeded finished
|
||||
db = request.result
|
||||
resolve(db)
|
||||
}
|
||||
|
||||
request.onerror = () => {
|
||||
reject(new Error('failed to open IndexedDB'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const WalletLoggerProvider = ({ children }) => {
|
||||
const me = useMe()
|
||||
const [logs, setLogs] = useState([])
|
||||
let dbName = 'app:storage'
|
||||
if (me) {
|
||||
dbName = `${dbName}:${me.id}`
|
||||
}
|
||||
const idbStoreName = 'wallet_logs'
|
||||
const idb = useRef()
|
||||
const logQueue = useRef([])
|
||||
|
||||
useQuery(WALLET_LOGS, {
|
||||
fetchPolicy: 'network-only',
|
||||
// required to trigger onCompleted on refetches
|
||||
notifyOnNetworkStatusChange: true,
|
||||
onCompleted: ({ walletLogs }) => {
|
||||
setLogs((prevLogs) => {
|
||||
const existingIds = prevLogs.map(({ id }) => id)
|
||||
const logs = walletLogs
|
||||
.filter(({ id }) => !existingIds.includes(id))
|
||||
.map(({ createdAt, wallet: walletType, ...log }) => {
|
||||
return {
|
||||
ts: +new Date(createdAt),
|
||||
wallet: tag(getWalletByType(walletType)),
|
||||
...log
|
||||
}
|
||||
})
|
||||
return [...prevLogs, ...logs].sort((a, b) => b.ts - a.ts)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const [deleteServerWalletLogs] = useMutation(
|
||||
gql`
|
||||
mutation deleteWalletLogs($wallet: String) {
|
||||
deleteWalletLogs(wallet: $wallet)
|
||||
}
|
||||
`,
|
||||
{
|
||||
onCompleted: (_, { variables: { wallet: walletType } }) => {
|
||||
setLogs((logs) => {
|
||||
return logs.filter(l => walletType ? l.wallet !== getWalletByType(walletType).name : false)
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const saveLog = useCallback((log) => {
|
||||
if (!idb.current) {
|
||||
// IDB may not be ready yet
|
||||
return logQueue.current.push(log)
|
||||
}
|
||||
const tx = idb.current.transaction(idbStoreName, 'readwrite')
|
||||
const request = tx.objectStore(idbStoreName).add(log)
|
||||
request.onerror = () => console.error('failed to save log:', log)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
initIndexedDB(dbName, idbStoreName)
|
||||
.then(db => {
|
||||
idb.current = db
|
||||
|
||||
// load all logs from IDB
|
||||
const tx = idb.current.transaction(idbStoreName, 'readonly')
|
||||
const store = tx.objectStore(idbStoreName)
|
||||
const index = store.index('ts')
|
||||
const request = index.getAll()
|
||||
request.onsuccess = () => {
|
||||
let logs = request.result
|
||||
setLogs((prevLogs) => {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
// in dev mode, useEffect runs twice, so we filter out duplicates here
|
||||
const existingIds = prevLogs.map(({ id }) => id)
|
||||
logs = logs.filter(({ id }) => !existingIds.includes(id))
|
||||
}
|
||||
// sort oldest first to keep same order as logs are appended
|
||||
return [...prevLogs, ...logs].sort((a, b) => b.ts - a.ts)
|
||||
})
|
||||
}
|
||||
|
||||
// flush queued logs to IDB
|
||||
logQueue.current.forEach(q => {
|
||||
const isLog = !!q.wallet
|
||||
if (isLog) saveLog(q)
|
||||
})
|
||||
|
||||
logQueue.current = []
|
||||
})
|
||||
.catch(console.error)
|
||||
return () => idb.current?.close()
|
||||
}, [])
|
||||
|
||||
const appendLog = useCallback((wallet, level, message) => {
|
||||
const log = { wallet: tag(wallet), level, message, ts: +new Date() }
|
||||
saveLog(log)
|
||||
setLogs((prevLogs) => [log, ...prevLogs])
|
||||
}, [saveLog])
|
||||
|
||||
const deleteLogs = useCallback(async (wallet) => {
|
||||
if (!wallet || wallet.walletType) {
|
||||
await deleteServerWalletLogs({ variables: { wallet: wallet?.walletType } })
|
||||
}
|
||||
if (!wallet || wallet.sendPayment) {
|
||||
const tx = idb.current.transaction(idbStoreName, 'readwrite')
|
||||
const objectStore = tx.objectStore(idbStoreName)
|
||||
const idx = objectStore.index('wallet_ts')
|
||||
const request = wallet ? idx.openCursor(window.IDBKeyRange.bound([tag(wallet), -Infinity], [tag(wallet), Infinity])) : idx.openCursor()
|
||||
request.onsuccess = function (event) {
|
||||
const cursor = event.target.result
|
||||
if (cursor) {
|
||||
cursor.delete()
|
||||
cursor.continue()
|
||||
} else {
|
||||
// finished
|
||||
setLogs((logs) => logs.filter(l => wallet ? l.wallet !== tag(wallet) : false))
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [me, setLogs])
|
||||
|
||||
return (
|
||||
<WalletLogsContext.Provider value={logs}>
|
||||
<WalletLoggerContext.Provider value={{ appendLog, deleteLogs }}>
|
||||
{children}
|
||||
</WalletLoggerContext.Provider>
|
||||
</WalletLogsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useWalletLogger (wallet) {
|
||||
const { appendLog, deleteLogs: innerDeleteLogs } = useContext(WalletLoggerContext)
|
||||
|
||||
const log = useCallback(level => message => {
|
||||
if (!wallet) {
|
||||
console.error('cannot log: no wallet set')
|
||||
return
|
||||
}
|
||||
|
||||
// don't store logs for receiving wallets on client since logs are stored on server
|
||||
if (wallet.walletType) return
|
||||
|
||||
// TODO:
|
||||
// also send this to us if diagnostics was enabled,
|
||||
// very similar to how the service worker logger works.
|
||||
appendLog(wallet, level, message)
|
||||
console[level !== 'error' ? 'info' : 'error'](`[${tag(wallet)}]`, message)
|
||||
}, [appendLog, wallet])
|
||||
|
||||
const logger = useMemo(() => ({
|
||||
ok: (...message) => log('ok')(message.join(' ')),
|
||||
info: (...message) => log('info')(message.join(' ')),
|
||||
error: (...message) => log('error')(message.join(' '))
|
||||
}), [log, wallet?.name])
|
||||
|
||||
const deleteLogs = useCallback((w) => innerDeleteLogs(w || wallet), [innerDeleteLogs, wallet])
|
||||
|
||||
return { logger, deleteLogs }
|
||||
}
|
||||
|
||||
function tag (wallet) {
|
||||
return wallet?.shortName || wallet?.name
|
||||
}
|
||||
|
||||
export function useWalletLogs (wallet) {
|
||||
const logs = useContext(WalletLogsContext)
|
||||
return logs.filter(l => !wallet || l.wallet === tag(wallet))
|
||||
}
|
|
@ -1,121 +0,0 @@
|
|||
import { useRouter } from 'next/router'
|
||||
import LogMessage from './log-message'
|
||||
import { useWalletLogger, useWalletLogs } from './logger'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Checkbox, Form } from './form'
|
||||
import { useField } from 'formik'
|
||||
import styles from '@/styles/log.module.css'
|
||||
import { Button } from 'react-bootstrap'
|
||||
import { useToast } from './toast'
|
||||
import { useShowModal } from './modal'
|
||||
|
||||
const FollowCheckbox = ({ value, ...props }) => {
|
||||
const [,, helpers] = useField(props.name)
|
||||
|
||||
useEffect(() => {
|
||||
helpers.setValue(value)
|
||||
}, [value])
|
||||
|
||||
return (
|
||||
<Checkbox {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export default function WalletLogs ({ wallet, embedded }) {
|
||||
const logs = useWalletLogs(wallet)
|
||||
|
||||
const router = useRouter()
|
||||
const { follow: defaultFollow } = router.query
|
||||
const [follow, setFollow] = useState(defaultFollow ?? true)
|
||||
const tableRef = useRef()
|
||||
const scrollY = useRef()
|
||||
const showModal = useShowModal()
|
||||
|
||||
useEffect(() => {
|
||||
if (follow) {
|
||||
tableRef.current?.scroll({ top: tableRef.current.scrollHeight, behavior: 'smooth' })
|
||||
}
|
||||
}, [logs, follow])
|
||||
|
||||
useEffect(() => {
|
||||
function onScroll (e) {
|
||||
const y = e.target.scrollTop
|
||||
|
||||
const down = y - scrollY.current >= -1
|
||||
if (!!scrollY.current && !down) {
|
||||
setFollow(false)
|
||||
}
|
||||
|
||||
const maxY = e.target.scrollHeight - e.target.clientHeight
|
||||
const dY = maxY - y
|
||||
const isBottom = dY >= -1 && dY <= 1
|
||||
if (isBottom) {
|
||||
setFollow(true)
|
||||
}
|
||||
|
||||
scrollY.current = y
|
||||
}
|
||||
tableRef.current?.addEventListener('scroll', onScroll)
|
||||
return () => tableRef.current?.removeEventListener('scroll', onScroll)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='d-flex w-100 align-items-center mb-3'>
|
||||
<Form initial={{ follow: true }}>
|
||||
<FollowCheckbox
|
||||
label='follow logs' name='follow' value={follow}
|
||||
handleChange={setFollow} groupClassName='mb-0'
|
||||
/>
|
||||
</Form>
|
||||
<span
|
||||
style={{ cursor: 'pointer' }}
|
||||
className='text-muted fw-bold nav-link' onClick={() => {
|
||||
showModal(onClose => <DeleteWalletLogsObstacle wallet={wallet} onClose={onClose} />)
|
||||
}}
|
||||
>clear
|
||||
</span>
|
||||
</div>
|
||||
<div ref={tableRef} className={`${styles.logTable} ${embedded ? styles.embedded : ''}`}>
|
||||
<div className='w-100 text-center'>------ start of logs ------</div>
|
||||
{logs.length === 0 && <div className='w-100 text-center'>empty</div>}
|
||||
<table>
|
||||
<tbody>
|
||||
{logs.map((log, i) => <LogMessage key={i} {...log} />)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function DeleteWalletLogsObstacle ({ wallet, onClose }) {
|
||||
const toaster = useToast()
|
||||
const { deleteLogs } = useWalletLogger(wallet)
|
||||
|
||||
const prompt = `Do you really want to delete all ${wallet ? '' : 'wallet'} logs ${wallet ? 'of this wallet' : ''}?`
|
||||
return (
|
||||
<div className='text-center'>
|
||||
{prompt}
|
||||
<div className='d-flex justify-center align-items-center mt-3 mx-auto'>
|
||||
<span style={{ cursor: 'pointer' }} className='d-flex ms-auto text-muted fw-bold nav-link mx-3' onClick={onClose}>cancel</span>
|
||||
<Button
|
||||
className='d-flex me-auto mx-3' variant='danger'
|
||||
onClick={
|
||||
async () => {
|
||||
try {
|
||||
await deleteLogs()
|
||||
onClose()
|
||||
toaster.success('deleted wallet logs')
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
toaster.danger('failed to delete wallet logs')
|
||||
}
|
||||
}
|
||||
}
|
||||
>delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -1,142 +0,0 @@
|
|||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import { LNbitsProvider, useLNbits } from './lnbits'
|
||||
import { NWCProvider, useNWC } from './nwc'
|
||||
import { LNCProvider, useLNC } from './lnc'
|
||||
|
||||
const WebLNContext = createContext({})
|
||||
|
||||
const isEnabled = p => [Status.Enabled, Status.Locked].includes(p?.status)
|
||||
|
||||
const syncProvider = (array, provider) => {
|
||||
const idx = array.findIndex(({ name }) => provider.name === name)
|
||||
const enabled = isEnabled(provider)
|
||||
if (idx === -1) {
|
||||
// add provider to end if enabled
|
||||
return enabled ? [...array, provider] : array
|
||||
}
|
||||
return [
|
||||
...array.slice(0, idx),
|
||||
// remove provider if not enabled
|
||||
...enabled ? [provider] : [],
|
||||
...array.slice(idx + 1)
|
||||
]
|
||||
}
|
||||
|
||||
const storageKey = 'webln:providers'
|
||||
|
||||
export const Status = {
|
||||
Initialized: 'Initialized',
|
||||
Enabled: 'Enabled',
|
||||
Locked: 'Locked',
|
||||
Error: 'Error'
|
||||
}
|
||||
|
||||
export function migrateLocalStorage (oldStorageKey, newStorageKey) {
|
||||
const item = window.localStorage.getItem(oldStorageKey)
|
||||
if (item) {
|
||||
window.localStorage.setItem(newStorageKey, item)
|
||||
window.localStorage.removeItem(oldStorageKey)
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
function RawWebLNProvider ({ children }) {
|
||||
const lnbits = useLNbits()
|
||||
const nwc = useNWC()
|
||||
const lnc = useLNC()
|
||||
const availableProviders = [lnbits, nwc, lnc]
|
||||
const [enabledProviders, setEnabledProviders] = useState([])
|
||||
|
||||
// restore order on page reload
|
||||
useEffect(() => {
|
||||
const storedOrder = window.localStorage.getItem(storageKey)
|
||||
if (!storedOrder) return
|
||||
const providerNames = JSON.parse(storedOrder)
|
||||
setEnabledProviders(providers => {
|
||||
return providerNames.map(name => {
|
||||
for (const p of availableProviders) {
|
||||
if (p.name === name) return p
|
||||
}
|
||||
console.warn(`Stored provider with name ${name} not available`)
|
||||
return null
|
||||
})
|
||||
})
|
||||
}, [])
|
||||
|
||||
// keep list in sync with underlying providers
|
||||
useEffect(() => {
|
||||
setEnabledProviders(providers => {
|
||||
// Sync existing provider state with new provider state
|
||||
// in the list while keeping the order they are in.
|
||||
// If provider does not exist but is enabled, it is just added to the end of the list.
|
||||
// This can be the case if we're syncing from a page reload
|
||||
// where the providers are initially not enabled.
|
||||
// If provider is no longer enabled, it is removed from the list.
|
||||
const isInitialized = p => [Status.Enabled, Status.Locked, Status.Initialized].includes(p.status)
|
||||
const newProviders = availableProviders.filter(isInitialized).reduce(syncProvider, providers)
|
||||
const newOrder = newProviders.map(({ name }) => name)
|
||||
window.localStorage.setItem(storageKey, JSON.stringify(newOrder))
|
||||
return newProviders
|
||||
})
|
||||
}, [...availableProviders])
|
||||
|
||||
// first provider in list is the default provider
|
||||
// TODO: implement fallbacks via provider priority
|
||||
const provider = enabledProviders[0]
|
||||
|
||||
const setProvider = useCallback((defaultProvider) => {
|
||||
// move provider to the start to set it as default
|
||||
setEnabledProviders(providers => {
|
||||
const idx = providers.findIndex(({ name }) => defaultProvider.name === name)
|
||||
if (idx === -1) {
|
||||
console.warn(`tried to set unenabled provider ${defaultProvider.name} as default`)
|
||||
return providers
|
||||
}
|
||||
return [defaultProvider, ...providers.slice(0, idx), ...providers.slice(idx + 1)]
|
||||
})
|
||||
}, [setEnabledProviders])
|
||||
|
||||
const clearConfig = useCallback(async () => {
|
||||
lnbits.clearConfig()
|
||||
nwc.clearConfig()
|
||||
await lnc.clearConfig()
|
||||
}, [])
|
||||
|
||||
const value = useMemo(() => ({
|
||||
provider: isEnabled(provider)
|
||||
? { name: provider.name, sendPayment: provider.sendPayment }
|
||||
: null,
|
||||
enabledProviders,
|
||||
setProvider,
|
||||
clearConfig
|
||||
}), [provider, enabledProviders, setProvider])
|
||||
|
||||
return (
|
||||
<WebLNContext.Provider value={value}>
|
||||
{children}
|
||||
</WebLNContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function WebLNProvider ({ children }) {
|
||||
return (
|
||||
<LNbitsProvider>
|
||||
<NWCProvider>
|
||||
<LNCProvider>
|
||||
<RawWebLNProvider>
|
||||
{children}
|
||||
</RawWebLNProvider>
|
||||
</LNCProvider>
|
||||
</NWCProvider>
|
||||
</LNbitsProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useWebLN () {
|
||||
const { provider } = useContext(WebLNContext)
|
||||
return provider
|
||||
}
|
||||
|
||||
export function useWebLNConfigurator () {
|
||||
return useContext(WebLNContext)
|
||||
}
|
|
@ -1,210 +0,0 @@
|
|||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import { useWalletLogger } from '../logger'
|
||||
import { Status, migrateLocalStorage } from '.'
|
||||
import { bolt11Tags } from '@/lib/bolt11'
|
||||
import { Wallet } from '@/lib/constants'
|
||||
import { useMe } from '../me'
|
||||
|
||||
// Reference: https://github.com/getAlby/bitcoin-connect/blob/v3.2.0-alpha/src/connectors/LnbitsConnector.ts
|
||||
|
||||
const LNbitsContext = createContext()
|
||||
|
||||
const getWallet = async (baseUrl, adminKey) => {
|
||||
const url = baseUrl.replace(/\/+$/, '')
|
||||
const path = '/api/v1/wallet'
|
||||
|
||||
const headers = new Headers()
|
||||
headers.append('Accept', 'application/json')
|
||||
headers.append('Content-Type', 'application/json')
|
||||
headers.append('X-Api-Key', adminKey)
|
||||
|
||||
const res = await fetch(url + path, { method: 'GET', headers })
|
||||
if (!res.ok) {
|
||||
const errBody = await res.json()
|
||||
throw new Error(errBody.detail)
|
||||
}
|
||||
const wallet = await res.json()
|
||||
return wallet
|
||||
}
|
||||
|
||||
const postPayment = async (baseUrl, adminKey, bolt11) => {
|
||||
const url = baseUrl.replace(/\/+$/, '')
|
||||
const path = '/api/v1/payments'
|
||||
|
||||
const headers = new Headers()
|
||||
headers.append('Accept', 'application/json')
|
||||
headers.append('Content-Type', 'application/json')
|
||||
headers.append('X-Api-Key', adminKey)
|
||||
|
||||
const body = JSON.stringify({ bolt11, out: true })
|
||||
|
||||
const res = await fetch(url + path, { method: 'POST', headers, body })
|
||||
if (!res.ok) {
|
||||
const errBody = await res.json()
|
||||
throw new Error(errBody.detail)
|
||||
}
|
||||
const payment = await res.json()
|
||||
return payment
|
||||
}
|
||||
|
||||
const getPayment = async (baseUrl, adminKey, paymentHash) => {
|
||||
const url = baseUrl.replace(/\/+$/, '')
|
||||
const path = `/api/v1/payments/${paymentHash}`
|
||||
|
||||
const headers = new Headers()
|
||||
headers.append('Accept', 'application/json')
|
||||
headers.append('Content-Type', 'application/json')
|
||||
headers.append('X-Api-Key', adminKey)
|
||||
|
||||
const res = await fetch(url + path, { method: 'GET', headers })
|
||||
if (!res.ok) {
|
||||
const errBody = await res.json()
|
||||
throw new Error(errBody.detail)
|
||||
}
|
||||
const payment = await res.json()
|
||||
return payment
|
||||
}
|
||||
|
||||
export function LNbitsProvider ({ children }) {
|
||||
const me = useMe()
|
||||
const [url, setUrl] = useState('')
|
||||
const [adminKey, setAdminKey] = useState('')
|
||||
const [status, setStatus] = useState()
|
||||
const { logger } = useWalletLogger(Wallet.LNbits)
|
||||
|
||||
let storageKey = 'webln:provider:lnbits'
|
||||
if (me) {
|
||||
storageKey = `${storageKey}:${me.id}`
|
||||
}
|
||||
|
||||
const getInfo = useCallback(async () => {
|
||||
const response = await getWallet(url, adminKey)
|
||||
return {
|
||||
node: {
|
||||
alias: response.name,
|
||||
pubkey: ''
|
||||
},
|
||||
methods: [
|
||||
'getInfo',
|
||||
'getBalance',
|
||||
'sendPayment'
|
||||
],
|
||||
version: '1.0',
|
||||
supports: ['lightning']
|
||||
}
|
||||
}, [url, adminKey])
|
||||
|
||||
const sendPayment = useCallback(async (bolt11) => {
|
||||
const hash = bolt11Tags(bolt11).payment_hash
|
||||
logger.info('sending payment:', `payment_hash=${hash}`)
|
||||
|
||||
try {
|
||||
const response = await postPayment(url, adminKey, bolt11)
|
||||
const checkResponse = await getPayment(url, adminKey, response.payment_hash)
|
||||
if (!checkResponse.preimage) {
|
||||
throw new Error('No preimage')
|
||||
}
|
||||
const preimage = checkResponse.preimage
|
||||
logger.ok('payment successful:', `payment_hash=${hash}`, `preimage=${preimage}`)
|
||||
return { preimage }
|
||||
} catch (err) {
|
||||
logger.error('payment failed:', `payment_hash=${hash}`, err.message || err.toString?.())
|
||||
throw err
|
||||
}
|
||||
}, [logger, url, adminKey])
|
||||
|
||||
const loadConfig = useCallback(async () => {
|
||||
let configStr = window.localStorage.getItem(storageKey)
|
||||
setStatus(Status.Initialized)
|
||||
if (!configStr) {
|
||||
if (me) {
|
||||
// backwards compatibility: try old storageKey
|
||||
const oldStorageKey = storageKey.split(':').slice(0, -1).join(':')
|
||||
configStr = migrateLocalStorage(oldStorageKey, storageKey)
|
||||
}
|
||||
if (!configStr) {
|
||||
logger.info('no existing config found')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const config = JSON.parse(configStr)
|
||||
|
||||
const { url, adminKey } = config
|
||||
setUrl(url)
|
||||
setAdminKey(adminKey)
|
||||
|
||||
logger.info(
|
||||
'loaded wallet config: ' +
|
||||
'adminKey=****** ' +
|
||||
`url=${url}`)
|
||||
|
||||
try {
|
||||
// validate config by trying to fetch wallet
|
||||
logger.info('trying to fetch wallet')
|
||||
await getWallet(url, adminKey)
|
||||
logger.ok('wallet found')
|
||||
setStatus(Status.Enabled)
|
||||
logger.ok('wallet enabled')
|
||||
} catch (err) {
|
||||
logger.error('invalid config:', err)
|
||||
setStatus(Status.Error)
|
||||
logger.info('wallet disabled')
|
||||
throw err
|
||||
}
|
||||
}, [me, logger])
|
||||
|
||||
const saveConfig = useCallback(async (config) => {
|
||||
// immediately store config so it's not lost even if config is invalid
|
||||
setUrl(config.url)
|
||||
setAdminKey(config.adminKey)
|
||||
|
||||
// XXX This is insecure, XSS vulns could lead to loss of funds!
|
||||
// -> check how mutiny encrypts their wallet and/or check if we can leverage web workers
|
||||
// https://thenewstack.io/leveraging-web-workers-to-safely-store-access-tokens/
|
||||
window.localStorage.setItem(storageKey, JSON.stringify(config))
|
||||
|
||||
logger.info(
|
||||
'saved wallet config: ' +
|
||||
'adminKey=****** ' +
|
||||
`url=${config.url}`)
|
||||
|
||||
try {
|
||||
// validate config by trying to fetch wallet
|
||||
logger.info('trying to fetch wallet')
|
||||
await getWallet(config.url, config.adminKey)
|
||||
logger.ok('wallet found')
|
||||
setStatus(Status.Enabled)
|
||||
logger.ok('wallet enabled')
|
||||
} catch (err) {
|
||||
logger.error('invalid config:', err)
|
||||
setStatus(Status.Error)
|
||||
logger.info('wallet disabled')
|
||||
throw err
|
||||
}
|
||||
}, [])
|
||||
|
||||
const clearConfig = useCallback(() => {
|
||||
window.localStorage.removeItem(storageKey)
|
||||
setUrl('')
|
||||
setAdminKey('')
|
||||
setStatus(undefined)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig().catch(console.error)
|
||||
}, [])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ name: 'LNbits', url, adminKey, status, saveConfig, clearConfig, getInfo, sendPayment }),
|
||||
[url, adminKey, status, saveConfig, clearConfig, getInfo, sendPayment])
|
||||
return (
|
||||
<LNbitsContext.Provider value={value}>
|
||||
{children}
|
||||
</LNbitsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useLNbits () {
|
||||
return useContext(LNbitsContext)
|
||||
}
|
|
@ -1,215 +0,0 @@
|
|||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import { useWalletLogger } from '../logger'
|
||||
import { Status, migrateLocalStorage } from '.'
|
||||
import { bolt11Tags } from '@/lib/bolt11'
|
||||
import useModal from '../modal'
|
||||
import { Form, PasswordInput, SubmitButton } from '../form'
|
||||
import CancelButton from '../cancel-button'
|
||||
import { Mutex } from 'async-mutex'
|
||||
import { Wallet } from '@/lib/constants'
|
||||
import { useMe } from '../me'
|
||||
import { InvoiceCanceledError, InvoiceExpiredError } from '@/components/payment'
|
||||
|
||||
const LNCContext = createContext()
|
||||
const mutex = new Mutex()
|
||||
|
||||
async function getLNC ({ me }) {
|
||||
if (window.lnc) return window.lnc
|
||||
const { default: LNC } = await import('@lightninglabs/lnc-web')
|
||||
// backwards compatibility: migrate to new storage key
|
||||
if (me) migrateLocalStorage('lnc-web:default', `lnc-web:stacker:${me.id}`)
|
||||
window.lnc = new LNC({ namespace: me?.id ? `stacker:${me.id}` : undefined })
|
||||
return window.lnc
|
||||
}
|
||||
|
||||
// default password if the user hasn't set one
|
||||
export const XXX_DEFAULT_PASSWORD = 'password'
|
||||
|
||||
function validateNarrowPerms (lnc) {
|
||||
if (!lnc.hasPerms('lnrpc.Lightning.SendPaymentSync')) {
|
||||
throw new Error('missing permission: lnrpc.Lightning.SendPaymentSync')
|
||||
}
|
||||
if (lnc.hasPerms('lnrpc.Lightning.SendCoins')) {
|
||||
throw new Error('too broad permission: lnrpc.Wallet.SendCoins')
|
||||
}
|
||||
// TODO: need to check for more narrow permissions
|
||||
// blocked by https://github.com/lightninglabs/lnc-web/issues/112
|
||||
}
|
||||
|
||||
export function LNCProvider ({ children }) {
|
||||
const me = useMe()
|
||||
const { logger } = useWalletLogger(Wallet.LNC)
|
||||
const [config, setConfig] = useState({})
|
||||
const [lnc, setLNC] = useState()
|
||||
const [status, setStatus] = useState()
|
||||
const [modal, showModal] = useModal()
|
||||
|
||||
const getInfo = useCallback(async () => {
|
||||
logger.info('getInfo called')
|
||||
return await lnc.lightning.getInfo()
|
||||
}, [logger, lnc])
|
||||
|
||||
const unlock = useCallback(async (connect) => {
|
||||
if (status === Status.Enabled) return config.password
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
const cancelAndReject = async () => {
|
||||
reject(new Error('password canceled'))
|
||||
}
|
||||
showModal(onClose => {
|
||||
return (
|
||||
<Form
|
||||
initial={{
|
||||
password: ''
|
||||
}}
|
||||
onSubmit={async (values) => {
|
||||
try {
|
||||
lnc.credentials.password = values?.password
|
||||
setStatus(Status.Enabled)
|
||||
setConfig({ pairingPhrase: lnc.credentials.pairingPhrase, password: values.password })
|
||||
logger.ok('wallet enabled')
|
||||
onClose()
|
||||
resolve(values.password)
|
||||
} catch (err) {
|
||||
logger.error('failed attempt to unlock wallet', err)
|
||||
throw err
|
||||
}
|
||||
}}
|
||||
>
|
||||
<h4 className='text-center mb-3'>Unlock LNC</h4>
|
||||
<PasswordInput
|
||||
label='password'
|
||||
name='password'
|
||||
/>
|
||||
<div className='mt-5 d-flex justify-content-between'>
|
||||
<CancelButton onClick={() => { onClose(); cancelAndReject() }} />
|
||||
<SubmitButton variant='primary'>unlock</SubmitButton>
|
||||
</div>
|
||||
</Form>
|
||||
)
|
||||
}, { onClose: cancelAndReject })
|
||||
})
|
||||
}, [logger, showModal, setConfig, lnc, status])
|
||||
|
||||
const sendPayment = useCallback(async (bolt11) => {
|
||||
const hash = bolt11Tags(bolt11).payment_hash
|
||||
logger.info('sending payment:', `payment_hash=${hash}`)
|
||||
|
||||
return await mutex.runExclusive(async () => {
|
||||
try {
|
||||
const password = await unlock()
|
||||
// credentials need to be decrypted before connecting after a disconnect
|
||||
lnc.credentials.password = password || XXX_DEFAULT_PASSWORD
|
||||
await lnc.connect()
|
||||
const { paymentError, paymentPreimage: preimage } =
|
||||
await lnc.lnd.lightning.sendPaymentSync({ payment_request: bolt11 })
|
||||
|
||||
if (paymentError) throw new Error(paymentError)
|
||||
if (!preimage) throw new Error('No preimage in response')
|
||||
|
||||
logger.ok('payment successful:', `payment_hash=${hash}`, `preimage=${preimage}`)
|
||||
return { preimage }
|
||||
} catch (err) {
|
||||
const msg = err.message || err.toString?.()
|
||||
logger.error('payment failed:', `payment_hash=${hash}`, msg)
|
||||
if (msg.includes('invoice expired')) {
|
||||
throw new InvoiceExpiredError(hash)
|
||||
}
|
||||
if (msg.includes('canceled')) {
|
||||
throw new InvoiceCanceledError(hash)
|
||||
}
|
||||
throw err
|
||||
} finally {
|
||||
try {
|
||||
lnc.disconnect()
|
||||
logger.info('disconnecting after:', `payment_hash=${hash}`)
|
||||
// wait for lnc to disconnect before releasing the mutex
|
||||
await new Promise((resolve, reject) => {
|
||||
let counter = 0
|
||||
const interval = setInterval(() => {
|
||||
if (lnc.isConnected) {
|
||||
if (counter++ > 100) {
|
||||
logger.error('failed to disconnect from lnc')
|
||||
clearInterval(interval)
|
||||
reject(new Error('failed to disconnect from lnc'))
|
||||
}
|
||||
return
|
||||
}
|
||||
clearInterval(interval)
|
||||
resolve()
|
||||
})
|
||||
}, 50)
|
||||
} catch (err) {
|
||||
logger.error('failed to disconnect from lnc', err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}, [logger, lnc, unlock])
|
||||
|
||||
const saveConfig = useCallback(async config => {
|
||||
setConfig(config)
|
||||
|
||||
try {
|
||||
lnc.credentials.pairingPhrase = config.pairingPhrase
|
||||
await lnc.connect()
|
||||
await validateNarrowPerms(lnc)
|
||||
lnc.credentials.password = config?.password || XXX_DEFAULT_PASSWORD
|
||||
setStatus(Status.Enabled)
|
||||
logger.ok('wallet enabled')
|
||||
} catch (err) {
|
||||
logger.error('invalid config:', err)
|
||||
setStatus(Status.Error)
|
||||
logger.info('wallet disabled')
|
||||
throw err
|
||||
} finally {
|
||||
lnc.disconnect()
|
||||
}
|
||||
}, [logger, lnc])
|
||||
|
||||
const clearConfig = useCallback(async () => {
|
||||
await lnc.credentials.clear(false)
|
||||
if (lnc.isConnected) lnc.disconnect()
|
||||
setStatus(undefined)
|
||||
setConfig({})
|
||||
logger.info('cleared config')
|
||||
}, [logger, lnc])
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const lnc = await getLNC({ me })
|
||||
setLNC(lnc)
|
||||
setStatus(Status.Initialized)
|
||||
if (lnc.credentials.isPaired) {
|
||||
try {
|
||||
// try the default password
|
||||
lnc.credentials.password = XXX_DEFAULT_PASSWORD
|
||||
} catch (err) {
|
||||
setStatus(Status.Locked)
|
||||
logger.info('wallet needs password before enabling')
|
||||
return
|
||||
}
|
||||
setStatus(Status.Enabled)
|
||||
setConfig({ pairingPhrase: lnc.credentials.pairingPhrase, password: lnc.credentials.password })
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('wallet could not be loaded:', err)
|
||||
setStatus(Status.Error)
|
||||
}
|
||||
})()
|
||||
}, [me, setStatus, setConfig, logger])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ name: 'lnc', status, unlock, getInfo, sendPayment, config, saveConfig, clearConfig }),
|
||||
[status, unlock, getInfo, sendPayment, config, saveConfig, clearConfig])
|
||||
return (
|
||||
<LNCContext.Provider value={value}>
|
||||
{children}
|
||||
{modal}
|
||||
</LNCContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useLNC () {
|
||||
return useContext(LNCContext)
|
||||
}
|
|
@ -1,287 +0,0 @@
|
|||
// https://github.com/getAlby/js-sdk/blob/master/src/webln/NostrWeblnProvider.ts
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import { Relay, finalizeEvent, nip04 } from 'nostr-tools'
|
||||
import { parseNwcUrl } from '@/lib/url'
|
||||
import { useWalletLogger } from '../logger'
|
||||
import { Status, migrateLocalStorage } from '.'
|
||||
import { bolt11Tags } from '@/lib/bolt11'
|
||||
import { JIT_INVOICE_TIMEOUT_MS, Wallet } from '@/lib/constants'
|
||||
import { useMe } from '../me'
|
||||
import { InvoiceExpiredError } from '../payment'
|
||||
|
||||
const NWCContext = createContext()
|
||||
|
||||
export function NWCProvider ({ children }) {
|
||||
const me = useMe()
|
||||
const [nwcUrl, setNwcUrl] = useState('')
|
||||
const [walletPubkey, setWalletPubkey] = useState()
|
||||
const [relayUrl, setRelayUrl] = useState()
|
||||
const [secret, setSecret] = useState()
|
||||
const [status, setStatus] = useState()
|
||||
const { logger } = useWalletLogger(Wallet.NWC)
|
||||
|
||||
let storageKey = 'webln:provider:nwc'
|
||||
if (me) {
|
||||
storageKey = `${storageKey}:${me.id}`
|
||||
}
|
||||
|
||||
const getInfo = useCallback(async (relayUrl, walletPubkey) => {
|
||||
logger.info(`requesting info event from ${relayUrl}`)
|
||||
|
||||
let relay
|
||||
try {
|
||||
relay = await Relay.connect(relayUrl)
|
||||
logger.ok(`connected to ${relayUrl}`)
|
||||
} catch (err) {
|
||||
const msg = `failed to connect to ${relayUrl}`
|
||||
logger.error(msg)
|
||||
throw new Error(msg)
|
||||
}
|
||||
|
||||
try {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const timeout = 5000
|
||||
const timer = setTimeout(() => {
|
||||
const msg = 'timeout waiting for info event'
|
||||
logger.error(msg)
|
||||
reject(new Error(msg))
|
||||
}, timeout)
|
||||
|
||||
let found = false
|
||||
relay.subscribe([
|
||||
{
|
||||
kinds: [13194],
|
||||
authors: [walletPubkey]
|
||||
}
|
||||
], {
|
||||
onevent (event) {
|
||||
clearTimeout(timer)
|
||||
found = true
|
||||
logger.ok(`received info event from ${relayUrl}`)
|
||||
resolve(event)
|
||||
},
|
||||
onclose (reason) {
|
||||
clearTimeout(timer)
|
||||
if (!['closed by caller', 'relay connection closed by us'].includes(reason)) {
|
||||
// only log if not closed by us (caller)
|
||||
const msg = 'connection closed: ' + (reason || 'unknown reason')
|
||||
logger.error(msg)
|
||||
reject(new Error(msg))
|
||||
}
|
||||
},
|
||||
oneose () {
|
||||
clearTimeout(timer)
|
||||
if (!found) {
|
||||
const msg = 'EOSE received without info event'
|
||||
logger.error(msg)
|
||||
reject(new Error(msg))
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
} finally {
|
||||
relay?.close()?.catch()
|
||||
if (relay) logger.info(`closed connection to ${relayUrl}`)
|
||||
}
|
||||
}, [logger])
|
||||
|
||||
const validateParams = useCallback(async ({ relayUrl, walletPubkey }) => {
|
||||
// validate connection by fetching info event
|
||||
// function needs to throw an error for formik validation to fail
|
||||
const event = await getInfo(relayUrl, walletPubkey)
|
||||
const supported = event.content.split(/[\s,]+/) // handle both spaces and commas
|
||||
logger.info('supported methods:', supported)
|
||||
if (!supported.includes('pay_invoice')) {
|
||||
const msg = 'wallet does not support pay_invoice'
|
||||
logger.error(msg)
|
||||
throw new Error(msg)
|
||||
}
|
||||
logger.ok('wallet supports pay_invoice')
|
||||
}, [logger])
|
||||
|
||||
const loadConfig = useCallback(async () => {
|
||||
let configStr = window.localStorage.getItem(storageKey)
|
||||
setStatus(Status.Initialized)
|
||||
if (!configStr) {
|
||||
if (me) {
|
||||
// backwards compatibility: try old storageKey
|
||||
const oldStorageKey = storageKey.split(':').slice(0, -1).join(':')
|
||||
configStr = migrateLocalStorage(oldStorageKey, storageKey)
|
||||
}
|
||||
if (!configStr) {
|
||||
logger.info('no existing config found')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const config = JSON.parse(configStr)
|
||||
|
||||
const { nwcUrl } = config
|
||||
setNwcUrl(nwcUrl)
|
||||
|
||||
const params = parseNwcUrl(nwcUrl)
|
||||
setRelayUrl(params.relayUrl)
|
||||
setWalletPubkey(params.walletPubkey)
|
||||
setSecret(params.secret)
|
||||
|
||||
logger.info(
|
||||
'loaded wallet config: ' +
|
||||
'secret=****** ' +
|
||||
`pubkey=${params.walletPubkey.slice(0, 6)}..${params.walletPubkey.slice(-6)} ` +
|
||||
`relay=${params.relayUrl}`)
|
||||
|
||||
try {
|
||||
await validateParams(params)
|
||||
setStatus(Status.Enabled)
|
||||
logger.ok('wallet enabled')
|
||||
} catch (err) {
|
||||
logger.error('invalid config:', err)
|
||||
setStatus(Status.Error)
|
||||
logger.info('wallet disabled')
|
||||
throw err
|
||||
}
|
||||
}, [me, validateParams, logger])
|
||||
|
||||
const saveConfig = useCallback(async (config) => {
|
||||
// immediately store config so it's not lost even if config is invalid
|
||||
const { nwcUrl } = config
|
||||
setNwcUrl(nwcUrl)
|
||||
if (!nwcUrl) {
|
||||
setStatus(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
const params = parseNwcUrl(nwcUrl)
|
||||
setRelayUrl(params.relayUrl)
|
||||
setWalletPubkey(params.walletPubkey)
|
||||
setSecret(params.secret)
|
||||
|
||||
// XXX Even though NWC allows to configure budget,
|
||||
// this is definitely not ideal from a security perspective.
|
||||
window.localStorage.setItem(storageKey, JSON.stringify(config))
|
||||
|
||||
logger.info(
|
||||
'saved wallet config: ' +
|
||||
'secret=****** ' +
|
||||
`pubkey=${params.walletPubkey.slice(0, 6)}..${params.walletPubkey.slice(-6)} ` +
|
||||
`relay=${params.relayUrl}`)
|
||||
|
||||
try {
|
||||
await validateParams(params)
|
||||
setStatus(Status.Enabled)
|
||||
logger.ok('wallet enabled')
|
||||
} catch (err) {
|
||||
logger.error('invalid config:', err)
|
||||
setStatus(Status.Error)
|
||||
logger.info('wallet disabled')
|
||||
throw err
|
||||
}
|
||||
}, [validateParams, logger])
|
||||
|
||||
const clearConfig = useCallback(() => {
|
||||
window.localStorage.removeItem(storageKey)
|
||||
setNwcUrl('')
|
||||
setRelayUrl(undefined)
|
||||
setWalletPubkey(undefined)
|
||||
setSecret(undefined)
|
||||
setStatus(undefined)
|
||||
}, [])
|
||||
|
||||
const sendPayment = useCallback(async (bolt11) => {
|
||||
const hash = bolt11Tags(bolt11).payment_hash
|
||||
logger.info('sending payment:', `payment_hash=${hash}`)
|
||||
|
||||
let relay
|
||||
try {
|
||||
relay = await Relay.connect(relayUrl)
|
||||
logger.ok(`connected to ${relayUrl}`)
|
||||
} catch (err) {
|
||||
const msg = `failed to connect to ${relayUrl}`
|
||||
logger.error(msg)
|
||||
throw new Error(msg)
|
||||
}
|
||||
|
||||
try {
|
||||
const ret = await new Promise(function (resolve, reject) {
|
||||
(async function () {
|
||||
// timeout since NWC is async (user needs to confirm payment in wallet)
|
||||
// timeout is same as invoice expiry
|
||||
const timeout = JIT_INVOICE_TIMEOUT_MS
|
||||
const timer = setTimeout(() => {
|
||||
const msg = 'timeout waiting for payment'
|
||||
logger.error(msg)
|
||||
reject(new InvoiceExpiredError(hash))
|
||||
}, timeout)
|
||||
|
||||
const payload = {
|
||||
method: 'pay_invoice',
|
||||
params: { invoice: bolt11 }
|
||||
}
|
||||
const content = await nip04.encrypt(secret, walletPubkey, JSON.stringify(payload))
|
||||
|
||||
const request = finalizeEvent({
|
||||
kind: 23194,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [['p', walletPubkey]],
|
||||
content
|
||||
}, secret)
|
||||
await relay.publish(request)
|
||||
|
||||
const filter = {
|
||||
kinds: [23195],
|
||||
authors: [walletPubkey],
|
||||
'#e': [request.id]
|
||||
}
|
||||
relay.subscribe([filter], {
|
||||
async onevent (response) {
|
||||
clearTimeout(timer)
|
||||
try {
|
||||
const content = JSON.parse(await nip04.decrypt(secret, walletPubkey, response.content))
|
||||
if (content.error) return reject(new Error(content.error.message))
|
||||
if (content.result) return resolve({ preimage: content.result.preimage })
|
||||
} catch (err) {
|
||||
return reject(err)
|
||||
}
|
||||
},
|
||||
onclose (reason) {
|
||||
clearTimeout(timer)
|
||||
if (!['closed by caller', 'relay connection closed by us'].includes(reason)) {
|
||||
// only log if not closed by us (caller)
|
||||
const msg = 'connection closed: ' + (reason || 'unknown reason')
|
||||
logger.error(msg)
|
||||
reject(new Error(msg))
|
||||
}
|
||||
}
|
||||
})
|
||||
})().catch(reject)
|
||||
})
|
||||
const preimage = ret.preimage
|
||||
logger.ok('payment successful:', `payment_hash=${hash}`, `preimage=${preimage}`)
|
||||
return ret
|
||||
} catch (err) {
|
||||
logger.error('payment failed:', `payment_hash=${hash}`, err.message || err.toString?.())
|
||||
throw err
|
||||
} finally {
|
||||
relay?.close()?.catch()
|
||||
if (relay) logger.info(`closed connection to ${relayUrl}`)
|
||||
}
|
||||
}, [walletPubkey, relayUrl, secret, logger])
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig().catch(err => logger.error(err.message || err.toString?.()))
|
||||
}, [])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ name: 'NWC', nwcUrl, relayUrl, walletPubkey, secret, status, saveConfig, clearConfig, getInfo, sendPayment }),
|
||||
[nwcUrl, relayUrl, walletPubkey, secret, status, saveConfig, clearConfig, getInfo, sendPayment])
|
||||
return (
|
||||
<NWCContext.Provider value={value}>
|
||||
{children}
|
||||
</NWCContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useNWC () {
|
||||
return useContext(NWCContext)
|
||||
}
|
|
@ -100,27 +100,6 @@ export const SEND_TO_LNADDR = gql`
|
|||
}
|
||||
}`
|
||||
|
||||
export const UPSERT_WALLET_LNADDR =
|
||||
gql`
|
||||
mutation upsertWalletLNAddr($id: ID, $address: String!, $settings: AutowithdrawSettings!) {
|
||||
upsertWalletLNAddr(id: $id, address: $address, settings: $settings)
|
||||
}
|
||||
`
|
||||
|
||||
export const UPSERT_WALLET_LND =
|
||||
gql`
|
||||
mutation upsertWalletLND($id: ID, $socket: String!, $macaroon: String!, $cert: String, $settings: AutowithdrawSettings!) {
|
||||
upsertWalletLND(id: $id, socket: $socket, macaroon: $macaroon, cert: $cert, settings: $settings)
|
||||
}
|
||||
`
|
||||
|
||||
export const UPSERT_WALLET_CLN =
|
||||
gql`
|
||||
mutation upsertWalletCLN($id: ID, $socket: String!, $rune: String!, $cert: String, $settings: AutowithdrawSettings!) {
|
||||
upsertWalletCLN(id: $id, socket: $socket, rune: $rune, cert: $cert, settings: $settings)
|
||||
}
|
||||
`
|
||||
|
||||
export const REMOVE_WALLET =
|
||||
gql`
|
||||
mutation removeWallet($id: ID!) {
|
||||
|
@ -160,6 +139,7 @@ export const WALLET_BY_TYPE = gql`
|
|||
walletByType(type: $type) {
|
||||
id
|
||||
createdAt
|
||||
enabled
|
||||
priority
|
||||
type
|
||||
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 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
|
||||
|
|
|
@ -22,7 +22,7 @@ function macaroonOPs (macaroon) {
|
|||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('macaroonOPs error:', e)
|
||||
console.error('macaroonOPs error:', e.message)
|
||||
}
|
||||
|
||||
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) {
|
||||
return this.test({
|
||||
name: 'or',
|
||||
|
@ -153,7 +161,7 @@ const floatValidator = number().typeError('must be a number')
|
|||
|
||||
const lightningAddressValidator = process.env.NODE_ENV === 'development'
|
||||
? 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')
|
||||
: string().email('address is no good')
|
||||
|
||||
|
@ -305,19 +313,22 @@ export function advSchema (args) {
|
|||
})
|
||||
}
|
||||
|
||||
export function lnAddrAutowithdrawSchema ({ me } = {}) {
|
||||
return object({
|
||||
export const autowithdrawSchemaMembers = {
|
||||
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({
|
||||
name: 'address',
|
||||
test: addr => !addr.endsWith('@stacker.news'),
|
||||
message: 'automated withdrawals must be external'
|
||||
}),
|
||||
...autowithdrawSchemaMembers({ me })
|
||||
...autowithdrawSchemaMembers
|
||||
})
|
||||
}
|
||||
|
||||
export function LNDAutowithdrawSchema ({ me } = {}) {
|
||||
return object({
|
||||
export const LNDAutowithdrawSchema = object({
|
||||
socket: string().socket().required('required'),
|
||||
macaroon: hexOrBase64Validator.required('required').test({
|
||||
name: 'macaroon',
|
||||
|
@ -325,12 +336,10 @@ export function LNDAutowithdrawSchema ({ me } = {}) {
|
|||
message: 'not an invoice macaroon or an invoicable macaroon'
|
||||
}),
|
||||
cert: hexOrBase64Validator,
|
||||
...autowithdrawSchemaMembers({ me })
|
||||
...autowithdrawSchemaMembers
|
||||
})
|
||||
}
|
||||
|
||||
export function CLNAutowithdrawSchema ({ me } = {}) {
|
||||
return object({
|
||||
export const CLNAutowithdrawSchema = object({
|
||||
socket: string().socket().required('required'),
|
||||
rune: string().matches(B64_URL_REGEX, { message: 'invalid rune' }).required('required')
|
||||
.test({
|
||||
|
@ -351,17 +360,8 @@ export function CLNAutowithdrawSchema ({ me } = {}) {
|
|||
}
|
||||
}),
|
||||
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) {
|
||||
return object({
|
||||
|
@ -622,7 +622,7 @@ export const lnbitsSchema = object({
|
|||
}
|
||||
return true
|
||||
}),
|
||||
adminKey: string().length(32)
|
||||
adminKey: string().length(32).required('required')
|
||||
})
|
||||
|
||||
export const nwcSchema = object({
|
||||
|
@ -657,13 +657,21 @@ export const lncSchema = object({
|
|||
if (this.isType(value) && value !== null) {
|
||||
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')
|
||||
.max(10, 'max 10 words')
|
||||
.required('required'),
|
||||
password: string()
|
||||
.required('required')
|
||||
})
|
||||
|
||||
export const bioSchema = object({
|
||||
|
|
|
@ -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(ignorePlugin)
|
||||
}
|
||||
|
||||
config.module.rules.push(
|
||||
|
|
|
@ -17,8 +17,8 @@ import { SSR } from '@/lib/constants'
|
|||
import NProgress from 'nprogress'
|
||||
import 'nprogress/nprogress.css'
|
||||
import { LoggerProvider } from '@/components/logger'
|
||||
import { WalletLoggerProvider } from '@/components/wallet-logger'
|
||||
import { ChainFeeProvider } from '@/components/chain-fee.js'
|
||||
import { WebLNProvider } from '@/components/webln'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { HasNewNotesProvider } from '@/components/use-has-new-notes'
|
||||
|
||||
|
@ -105,11 +105,11 @@ export default function MyApp ({ Component, pageProps: { ...props } }) {
|
|||
<MeProvider me={me}>
|
||||
<HasNewNotesProvider>
|
||||
<LoggerProvider>
|
||||
<WalletLoggerProvider>
|
||||
<ServiceWorkerProvider>
|
||||
<PriceProvider price={price}>
|
||||
<LightningProvider>
|
||||
<ToastProvider>
|
||||
<WebLNProvider>
|
||||
<ShowModalProvider>
|
||||
<BlockHeightProvider blockHeight={blockHeight}>
|
||||
<ChainFeeProvider chainFee={chainFee}>
|
||||
|
@ -120,11 +120,11 @@ export default function MyApp ({ Component, pageProps: { ...props } }) {
|
|||
</ChainFeeProvider>
|
||||
</BlockHeightProvider>
|
||||
</ShowModalProvider>
|
||||
</WebLNProvider>
|
||||
</ToastProvider>
|
||||
</LightningProvider>
|
||||
</PriceProvider>
|
||||
</ServiceWorkerProvider>
|
||||
</WalletLoggerProvider>
|
||||
</LoggerProvider>
|
||||
</HasNewNotesProvider>
|
||||
</MeProvider>
|
||||
|
|
|
@ -9,8 +9,9 @@ export default async ({ query: { username } }, res) => {
|
|||
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({
|
||||
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`
|
||||
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
|
||||
|
|
|
@ -12,7 +12,7 @@ export default function FullInvoice () {
|
|||
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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 Layout from '@/components/layout'
|
||||
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 { 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 }) {
|
||||
const { data } = useQuery(WALLETS)
|
||||
const { wallets } = useWallets()
|
||||
|
||||
if (!data && !ssrData) return <PageLoading />
|
||||
const { wallets } = data || ssrData
|
||||
const lnd = wallets.find(w => w.type === W.LND.type)
|
||||
const lnaddr = wallets.find(w => w.type === W.LnAddr.type)
|
||||
const cln = wallets.find(w => w.type === W.CLN.type)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [sourceIndex, setSourceIndex] = useState(null)
|
||||
const [targetIndex, setTargetIndex] = useState(null)
|
||||
|
||||
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 (
|
||||
<Layout>
|
||||
|
@ -35,16 +81,42 @@ export default function Wallet ({ ssrData }) {
|
|||
wallet logs
|
||||
</Link>
|
||||
</div>
|
||||
<div className={styles.walletGrid}>
|
||||
<LightningAddressWalletCard wallet={lnaddr} />
|
||||
<LNDCard wallet={lnd} />
|
||||
<CLNCard wallet={cln} />
|
||||
<LNbitsCard />
|
||||
<NWCCard />
|
||||
<LNCCard />
|
||||
<WalletCard title='coming soon' badges={['probably']} />
|
||||
<WalletCard title='coming soon' badges={['we hope']} />
|
||||
<WalletCard title='coming soon' badges={['tm']} />
|
||||
<div className={styles.walletGrid} onDragEnd={onDragEnd}>
|
||||
{wallets
|
||||
.sort((w1, w2) => {
|
||||
// enabled/configured wallets always come before disabled/unconfigured wallets
|
||||
if ((w1.enabled && !w2.enabled) || (w1.isConfigured && !w2.isConfigured)) {
|
||||
return -1
|
||||
} else if ((w2.enabled && !w1.enabled) || (w2.isConfigured && !w1.isConfigured)) {
|
||||
return 1
|
||||
}
|
||||
|
||||
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>
|
||||
</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 { getGetServerSideProps } from '@/api/ssrApollo'
|
||||
import WalletLogs from '@/components/wallet-logs'
|
||||
import { WalletLogs } from '@/components/wallet-logger'
|
||||
|
||||
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")
|
||||
userId Int
|
||||
label String?
|
||||
enabled Boolean @default(true)
|
||||
priority Int @default(0)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
|
|
|
@ -7,6 +7,14 @@
|
|||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
.drag {
|
||||
opacity: 33%;
|
||||
}
|
||||
|
||||
.drop {
|
||||
box-shadow: 0 0 10px var(--bs-info);
|
||||
}
|
||||
|
||||
.card {
|
||||
width: 160px;
|
||||
height: 180px;
|
||||
|
|
|
@ -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]
|
||||
> ```
|
|
@ -0,0 +1,8 @@
|
|||
import * as nwc from 'wallets/nwc/client'
|
||||
import * as lnbits from 'wallets/lnbits/client'
|
||||
import * as lnc from 'wallets/lnc/client'
|
||||
import * as lnAddr from 'wallets/lightning-address/client'
|
||||
import * as cln from 'wallets/cln/client'
|
||||
import * as lnd from 'wallets/lnd/client'
|
||||
|
||||
export default [nwc, lnbits, lnc, lnAddr, cln, lnd]
|
|
@ -0,0 +1,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
|
||||
```
|
|
@ -0,0 +1 @@
|
|||
export * from 'wallets/cln'
|
|
@ -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'
|
|
@ -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
|
||||
}
|
|
@ -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))
|
||||
}
|
|
@ -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`.
|
|
@ -0,0 +1 @@
|
|||
export * from 'wallets/lightning-address'
|
|
@ -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'
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
|
@ -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.
|
||||
|
|
@ -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 = {}
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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
|
||||
```
|
|
@ -0,0 +1 @@
|
|||
export * from 'wallets/lnd'
|
|
@ -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'
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
```
|
|
@ -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}`)
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"type": "module"
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
import * as lnd from 'wallets/lnd/server'
|
||||
import * as cln from 'wallets/cln/server'
|
||||
import * as lnAddr from 'wallets/lightning-address/server'
|
||||
|
||||
export default [lnd, cln, lnAddr]
|
|
@ -1,9 +1,6 @@
|
|||
import { authenticatedLndGrpc, createInvoice } from 'ln-service'
|
||||
import { msatsToSats, satsToMsats } from '@/lib/format'
|
||||
import { datePivot } from '@/lib/time'
|
||||
import { createWithdrawal, sendToLnAddr, addWalletLog } from '@/api/resolvers/wallet'
|
||||
import { createInvoice as createInvoiceCLN } from '@/lib/cln'
|
||||
import { Wallet } from '@/lib/constants'
|
||||
import { createWithdrawal, addWalletLog } from '@/api/resolvers/wallet'
|
||||
import walletDefs from 'wallets/server'
|
||||
|
||||
export async function autoWithdraw ({ data: { id }, models, lnd }) {
|
||||
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
|
||||
const wallets = await models.wallet.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { priority: 'desc' }
|
||||
where: { userId: user.id, enabled: true },
|
||||
orderBy: [
|
||||
{ priority: 'asc' },
|
||||
// use id as tie breaker (older wallet first)
|
||||
{ id: 'asc' }
|
||||
]
|
||||
})
|
||||
|
||||
for (const wallet of wallets) {
|
||||
const w = walletDefs.find(w => w.walletType === wallet.type)
|
||||
try {
|
||||
if (wallet.type === Wallet.LND.type) {
|
||||
await autowithdrawLND(
|
||||
const { walletType, walletField, createInvoice } = w
|
||||
return await autowithdraw(
|
||||
{ walletType, walletField, createInvoice },
|
||||
{ amount, maxFee },
|
||||
{ models, me: user, 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
|
||||
{ me: user, models, lnd }
|
||||
)
|
||||
} catch (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 } }]
|
||||
const details = error[2]?.err?.details || error.message || error.toString?.()
|
||||
await addWalletLog({
|
||||
|
@ -77,9 +73,10 @@ export async function autoWithdraw ({ data: { id }, models, lnd }) {
|
|||
// none of the wallets worked
|
||||
}
|
||||
|
||||
async function autowithdrawLNAddr (
|
||||
async function autowithdraw (
|
||||
{ walletType, walletField, createInvoice: walletCreateInvoice },
|
||||
{ amount, maxFee },
|
||||
{ me, models, lnd, headers, autoWithdraw = false }) {
|
||||
{ me, models, lnd }) {
|
||||
if (!me) {
|
||||
throw new Error('me not specified')
|
||||
}
|
||||
|
@ -87,86 +84,25 @@ async function autowithdrawLNAddr (
|
|||
const wallet = await models.wallet.findFirst({
|
||||
where: {
|
||||
userId: me.id,
|
||||
type: Wallet.LnAddr.type
|
||||
type: walletType
|
||||
},
|
||||
include: {
|
||||
walletLightningAddress: true
|
||||
[walletField]: true
|
||||
}
|
||||
})
|
||||
|
||||
if (!wallet || !wallet.walletLightningAddress) {
|
||||
throw new Error('no lightning address wallet found')
|
||||
if (!wallet || !wallet[walletField]) {
|
||||
throw new Error(`no ${walletType} wallet found`)
|
||||
}
|
||||
|
||||
const { walletLightningAddress: { address } } = wallet
|
||||
return await sendToLnAddr(null, { addr: address, amount, maxFee }, { me, models, lnd, walletId: wallet.id })
|
||||
}
|
||||
|
||||
async function autowithdrawLND ({ 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.LND.type
|
||||
},
|
||||
include: {
|
||||
walletLND: true
|
||||
}
|
||||
const bolt11 = await walletCreateInvoice(
|
||||
{ amount, maxFee },
|
||||
wallet[walletField],
|
||||
{
|
||||
me,
|
||||
models,
|
||||
lnd
|
||||
})
|
||||
|
||||
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 })
|
||||
return await createWithdrawal(null, { invoice: bolt11, maxFee }, { me, models, lnd, walletId: wallet.id })
|
||||
}
|
||||
|
|
|
@ -253,7 +253,7 @@ async function checkWithdrawal ({ data: { hash }, boss, models, lnd }) {
|
|||
if (dbWdrwl.wallet) {
|
||||
// this was an autowithdrawal
|
||||
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) {
|
||||
|
@ -281,7 +281,7 @@ async function checkWithdrawal ({ data: { hash }, boss, models, lnd }) {
|
|||
if (code === 0 && dbWdrwl.wallet) {
|
||||
// add error into log for autowithdrawal
|
||||
await addWalletLog({
|
||||
wallet: dbWdrwl.wallet.type,
|
||||
wallet: dbWdrwl.wallet,
|
||||
level: 'ERROR',
|
||||
message: 'autowithdrawal failed: ' + message
|
||||
}, { models, me: { id: dbWdrwl.userId } })
|
||||
|
|
Loading…
Reference in New Issue