Compare commits
	
		
			113 Commits
		
	
	
		
			master
			...
			wallet-int
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 1b6de0bb96 | ||
|  | a0c1d4f602 | ||
|  | 5d03e08514 | ||
|  | 6a5713034b | ||
|  | c8d91bf42d | ||
|  | 08a5ce1a28 | ||
|  | 4df0b460c3 | ||
|  | 587bfa34be | ||
|  | 3933a4f460 | ||
|  | 667cde6042 | ||
|  | 6432ea7b44 | ||
|  | fb2b34ce67 | ||
|  | 9587ff9a52 | ||
|  | 538f1e21d6 | ||
|  | e25a3dbec0 | ||
|  | 128f1f93b8 | ||
|  | b777fdcddc | ||
|  | bbcfc2fada | ||
|  | 5b2e835722 | ||
|  | 259ebef971 | ||
|  | 7851366cd5 | ||
|  | cba76444dd | ||
|  | f01ce79afa | ||
|  | 03ca84629b | ||
|  | 7749c14d3b | ||
|  | ee1574cf45 | ||
|  | 6ac675429c | ||
|  | c767e106a0 | ||
|  | 6e6af40eb9 | ||
|  | 05c0f8a66e | ||
|  | 80756f23a4 | ||
|  | 24bdf0a099 | ||
|  | d9205b6d30 | ||
|  | 7402885998 | ||
|  | 1a60f13d72 | ||
|  | 920478a72c | ||
|  | 9af8e63355 | ||
|  | 8a36bffb85 | ||
|  | 8ea4d0c8a7 | ||
|  | 2051dd0e88 | ||
|  | 5d678ced23 | ||
|  | 459478036f | ||
|  | a69bca0f05 | ||
|  | 85cfda330b | ||
|  | 85464f93b9 | ||
|  | dddbb53792 | ||
|  | ebe741dc92 | ||
|  | 6bee659f2f | ||
|  | bd0e4d906c | ||
|  | 7528e5c2b6 | ||
|  | 1ce09051b1 | ||
|  | 8dac53d7d5 | ||
|  | cd074a47b7 | ||
|  | 12bedae01a | ||
|  | b569c8faa0 | ||
|  | ba00c3d9fa | ||
|  | 00f78daadc | ||
|  | 0a0085fe82 | ||
|  | 48ead97615 | ||
|  | 6463e6eec8 | ||
|  | 0ebe097a70 | ||
|  | 850c534c91 | ||
|  | 83fd39b035 | ||
|  | 9bbf2056e9 | ||
|  | 8acf74c787 | ||
|  | 55928ac252 | ||
|  | c270805649 | ||
|  | eb2f4b980f | ||
|  | b96757b366 | ||
|  | 39d8928772 | ||
|  | da6d262e0a | ||
|  | d20e258649 | ||
|  | d60e26bfdf | ||
|  | 9509833b88 | ||
|  | 645ff78365 | ||
|  | c18263dc73 | ||
|  | d8e82ddea5 | ||
|  | e091377d94 | ||
|  | 5b561e22a9 | ||
|  | 4bf9954c4e | ||
|  | 3b0605a691 | ||
|  | 1f98a1a891 | ||
|  | 377ac04c85 | ||
|  | 9228328d3b | ||
|  | 2aa0c9bc99 | ||
|  | d7c81cfa9f | ||
|  | 4a16cc17aa | ||
|  | 4082a45618 | ||
|  | ae0335d537 | ||
|  | 91978171ed | ||
|  | dae69ec4b3 | ||
|  | eda7fd6b46 | ||
|  | fd08356d37 | ||
|  | 61be80446d | ||
|  | 6059e8f691 | ||
|  | 1bae891594 | ||
|  | 276e734a7a | ||
|  | 7b6602e386 | ||
|  | 8e2dd45e23 | ||
|  | 7639390a16 | ||
|  | 29646eb956 | ||
|  | dd47f2c02b | ||
|  | a5ea53dc39 | ||
|  | 399c62a7e3 | ||
|  | 034cb4e8b2 | ||
|  | b8b0a4f985 | ||
|  | 0957cb5b83 | ||
|  | 71c753810c | ||
|  | 0de82db78a | ||
|  | 1a2be99027 | ||
|  | 6ac8785c51 | ||
|  | a1b343ac89 | ||
|  | 5f047cbfc9 | 
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @ -20,7 +20,6 @@ node_modules/ | ||||
| .DS_Store | ||||
| *.pem | ||||
| /*.sql | ||||
| lnbits/ | ||||
| 
 | ||||
| # debug | ||||
| npm-debug.log* | ||||
|  | ||||
| @ -1,19 +1,40 @@ | ||||
| import { getIdentity, createHodlInvoice, createInvoice, decodePaymentRequest, payViaPaymentRequest, cancelHodlInvoice, getInvoice as getInvoiceFromLnd, getNode, authenticatedLndGrpc, deletePayment, getPayment } from 'ln-service' | ||||
| import { createHodlInvoice, createInvoice, decodePaymentRequest, payViaPaymentRequest, cancelHodlInvoice, getInvoice as getInvoiceFromLnd, getNode, deletePayment, getPayment, getIdentity } from 'ln-service' | ||||
| import { GraphQLError } from 'graphql' | ||||
| import 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 } 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, generateSchema } from '@/lib/wallet' | ||||
| import { lnAddrOptions } from '@/lib/lnurl' | ||||
| 
 | ||||
| function injectResolvers (resolvers) { | ||||
|   console.group('injected GraphQL resolvers:') | ||||
|   for (const w of walletDefs) { | ||||
|     const { walletType, walletField, testConnect } = w | ||||
|     const resolverName = generateResolverName(walletField) | ||||
|     console.log(resolverName) | ||||
|     resolvers.Mutation[resolverName] = async (parent, { settings, ...data }, { me, models }) => { | ||||
|       return await upsertWallet({ | ||||
|         schema: generateSchema(w), | ||||
|         wallet: { field: walletField, type: walletType }, | ||||
|         testConnect: (data) => | ||||
|           testConnect(data, { me, models }) | ||||
|       }, { settings, data }, { me, models }) | ||||
|     } | ||||
|   } | ||||
|   console.groupEnd() | ||||
| 
 | ||||
|   return resolvers | ||||
| } | ||||
| 
 | ||||
| export async function getInvoice (parent, { id }, { me, models, lnd }) { | ||||
|   const inv = await models.invoice.findUnique({ | ||||
| @ -93,7 +114,7 @@ export function createHmac (hash) { | ||||
|   return crypto.createHmac('sha256', key).update(Buffer.from(hash, 'hex')).digest('hex') | ||||
| } | ||||
| 
 | ||||
| export default { | ||||
| const resolvers = { | ||||
|   Query: { | ||||
|     invoice: getInvoice, | ||||
|     wallet: async (parent, { id }, { me, models }) => { | ||||
| @ -318,9 +339,10 @@ export default { | ||||
|         where: { | ||||
|           userId: me.id | ||||
|         }, | ||||
|         orderBy: { | ||||
|           createdAt: 'asc' | ||||
|         } | ||||
|         orderBy: [ | ||||
|           { createdAt: 'desc' }, | ||||
|           { id: 'desc' } | ||||
|         ] | ||||
|       }) | ||||
|     } | ||||
|   }, | ||||
| @ -423,85 +445,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 +457,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 +541,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 } }) | ||||
| @ -620,13 +565,14 @@ async function upsertWallet ( | ||||
|       await testConnect(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 +584,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 +598,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 +698,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 +757,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 +772,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> | ||||
|  | ||||
| @ -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> | ||||
|       {children} | ||||
|     </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 ( | ||||
|     <> | ||||
|  | ||||
							
								
								
									
										21
									
								
								components/use-local-state.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								components/use-local-state.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,21 @@ | ||||
| import { SSR } from '@/lib/constants' | ||||
| import { useCallback, useState } from 'react' | ||||
| 
 | ||||
| export default function useLocalState (storageKey, initialValue = '') { | ||||
|   const [value, innerSetValue] = useState( | ||||
|     initialValue || | ||||
|     (SSR ? null : JSON.parse(window.localStorage.getItem(storageKey))) | ||||
|   ) | ||||
| 
 | ||||
|   const setValue = useCallback((newValue) => { | ||||
|     window.localStorage.setItem(storageKey, JSON.stringify(newValue)) | ||||
|     innerSetValue(newValue) | ||||
|   }, [storageKey]) | ||||
| 
 | ||||
|   const clearValue = useCallback(() => { | ||||
|     window.localStorage.removeItem(storageKey) | ||||
|     innerSetValue(null) | ||||
|   }, [storageKey]) | ||||
| 
 | ||||
|   return [value, setValue, clearValue] | ||||
| } | ||||
| @ -1,6 +1,6 @@ | ||||
| import { useApolloClient, useLazyQuery, useMutation } from '@apollo/client' | ||||
| import { 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,27 +22,27 @@ 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, { 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 (Date.now() - start > 1000 || err instanceof InvoiceCanceledError || err instanceof InvoiceExpiredError) { | ||||
|         // bail since qr code payment will also fail
 | ||||
|         // 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 | ||||
|  | ||||
							
								
								
									
										23
									
								
								components/wallet-buttonbar.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								components/wallet-buttonbar.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,23 @@ | ||||
| import { Button } from 'react-bootstrap' | ||||
| import CancelButton from './cancel-button' | ||||
| import { SubmitButton } from './form' | ||||
| 
 | ||||
| export default function WalletButtonBar ({ | ||||
|   wallet, disable, | ||||
|   className, children, onDelete, onCancel, hasCancel = true, | ||||
|   createText = 'attach', deleteText = 'detach', editText = 'save' | ||||
| }) { | ||||
|   return ( | ||||
|     <div className={`mt-3 ${className}`}> | ||||
|       <div className='d-flex justify-content-between'> | ||||
|         {wallet.isConfigured && | ||||
|           <Button onClick={onDelete} variant='grey-medium'>{deleteText}</Button>} | ||||
|         {children} | ||||
|         <div className='d-flex align-items-center ms-auto'> | ||||
|           {hasCancel && <CancelButton onClick={onCancel} />} | ||||
|           <SubmitButton variant='primary' disabled={disable}>{wallet.isConfigured ? editText : createText}</SubmitButton> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|   ) | ||||
| } | ||||
| @ -1,18 +1,15 @@ | ||||
| import { Badge, Button, Card } from 'react-bootstrap' | ||||
| import { Badge, Card } from 'react-bootstrap' | ||||
| import styles from '@/styles/wallet.module.css' | ||||
| import 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}`}> | ||||
|           <Card.Footer className={styles.attach}> | ||||
|             {configured | ||||
|               ? <>configure<Gear width={14} height={14} /></> | ||||
|               : <>attach<Plug width={14} height={14} /></>} | ||||
|           </Card.Footer> | ||||
|         </Link>} | ||||
|       <Link href={`/settings/wallets/${wallet.name}`}> | ||||
|         <Card.Footer className={styles.attach}> | ||||
|           {wallet.isConfigured | ||||
|             ? <>configure<Gear width={14} height={14} /></> | ||||
|             : <>attach<Plug width={14} height={14} /></>} | ||||
|         </Card.Footer> | ||||
|       </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> | ||||
|   ) | ||||
| } | ||||
|  | ||||
							
								
								
									
										274
									
								
								components/wallet-logger.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										274
									
								
								components/wallet-logger.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,274 @@ | ||||
| import LogMessage from './log-message' | ||||
| import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' | ||||
| import styles from '@/styles/log.module.css' | ||||
| import { Button } from 'react-bootstrap' | ||||
| import { useToast } from './toast' | ||||
| import { useShowModal } from './modal' | ||||
| import { WALLET_LOGS } from '@/fragments/wallet' | ||||
| import { getWalletByType } from 'wallets' | ||||
| import { gql, useMutation, useQuery } from '@apollo/client' | ||||
| import { useMe } from './me' | ||||
| 
 | ||||
| export function WalletLogs ({ wallet, embedded }) { | ||||
|   const logs = useWalletLogs(wallet) | ||||
| 
 | ||||
|   const tableRef = useRef() | ||||
|   const showModal = useShowModal() | ||||
| 
 | ||||
|   return ( | ||||
|     <> | ||||
|       <div className='d-flex w-100 align-items-center mb-3'> | ||||
|         <span | ||||
|           style={{ cursor: 'pointer' }} | ||||
|           className='text-muted fw-bold nav-link ms-auto' onClick={() => { | ||||
|             showModal(onClose => <DeleteWalletLogsObstacle wallet={wallet} onClose={onClose} />) | ||||
|           }} | ||||
|         >clear logs | ||||
|         </span> | ||||
|       </div> | ||||
|       <div ref={tableRef} className={`${styles.logTable} ${embedded ? styles.embedded : ''}`}> | ||||
|         {logs.length === 0 && <div className='w-100 text-center'>empty</div>} | ||||
|         <table> | ||||
|           <tbody> | ||||
|             {logs.map((log, i) => <LogMessage key={i} {...log} />)} | ||||
|           </tbody> | ||||
|         </table> | ||||
|         <div className='w-100 text-center'>------ start of logs ------</div> | ||||
|       </div> | ||||
|     </> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| function DeleteWalletLogsObstacle ({ wallet, onClose }) { | ||||
|   const toaster = useToast() | ||||
|   const { deleteLogs } = useWalletLogger(wallet) | ||||
| 
 | ||||
|   const prompt = `Do you really want to delete all ${wallet ? '' : 'wallet'} logs ${wallet ? 'of this wallet' : ''}?` | ||||
|   return ( | ||||
|     <div className='text-center'> | ||||
|       {prompt} | ||||
|       <div className='d-flex justify-center align-items-center mt-3 mx-auto'> | ||||
|         <span style={{ cursor: 'pointer' }} className='d-flex ms-auto text-muted fw-bold nav-link mx-3' onClick={onClose}>cancel</span> | ||||
|         <Button | ||||
|           className='d-flex me-auto mx-3' variant='danger' | ||||
|           onClick={ | ||||
|             async () => { | ||||
|               try { | ||||
|                 await deleteLogs() | ||||
|                 onClose() | ||||
|                 toaster.success('deleted wallet logs') | ||||
|               } catch (err) { | ||||
|                 console.error(err) | ||||
|                 toaster.danger('failed to delete wallet logs') | ||||
|               } | ||||
|             } | ||||
|           } | ||||
|         >delete | ||||
|         </Button> | ||||
|       </div> | ||||
|     </div> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| const WalletLoggerContext = createContext() | ||||
| const WalletLogsContext = createContext() | ||||
| 
 | ||||
| const initIndexedDB = async (dbName, storeName) => { | ||||
|   return new Promise((resolve, reject) => { | ||||
|     if (!window.indexedDB) { | ||||
|       return reject(new Error('IndexedDB not supported')) | ||||
|     } | ||||
| 
 | ||||
|     // https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API/Using_IndexedDB
 | ||||
|     const request = window.indexedDB.open(dbName, 1) | ||||
| 
 | ||||
|     let db | ||||
|     request.onupgradeneeded = () => { | ||||
|       // this only runs if version was changed during open
 | ||||
|       db = request.result | ||||
|       if (!db.objectStoreNames.contains(storeName)) { | ||||
|         const objectStore = db.createObjectStore(storeName, { autoIncrement: true }) | ||||
|         objectStore.createIndex('ts', 'ts') | ||||
|         objectStore.createIndex('wallet_ts', ['wallet', 'ts']) | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     request.onsuccess = () => { | ||||
|       // this gets called after onupgradeneeded finished
 | ||||
|       db = request.result | ||||
|       resolve(db) | ||||
|     } | ||||
| 
 | ||||
|     request.onerror = () => { | ||||
|       reject(new Error('failed to open IndexedDB')) | ||||
|     } | ||||
|   }) | ||||
| } | ||||
| 
 | ||||
| export const WalletLoggerProvider = ({ children }) => { | ||||
|   const me = useMe() | ||||
|   const [logs, setLogs] = useState([]) | ||||
|   let dbName = 'app:storage' | ||||
|   if (me) { | ||||
|     dbName = `${dbName}:${me.id}` | ||||
|   } | ||||
|   const idbStoreName = 'wallet_logs' | ||||
|   const idb = useRef() | ||||
|   const logQueue = useRef([]) | ||||
| 
 | ||||
|   useQuery(WALLET_LOGS, { | ||||
|     fetchPolicy: 'network-only', | ||||
|     // required to trigger onCompleted on refetches
 | ||||
|     notifyOnNetworkStatusChange: true, | ||||
|     onCompleted: ({ walletLogs }) => { | ||||
|       setLogs((prevLogs) => { | ||||
|         const existingIds = prevLogs.map(({ id }) => id) | ||||
|         const logs = walletLogs | ||||
|           .filter(({ id }) => !existingIds.includes(id)) | ||||
|           .map(({ createdAt, wallet: walletType, ...log }) => { | ||||
|             return { | ||||
|               ts: +new Date(createdAt), | ||||
|               wallet: tag(getWalletByType(walletType)), | ||||
|               ...log | ||||
|             } | ||||
|           }) | ||||
|         return [...prevLogs, ...logs].sort((a, b) => b.ts - a.ts) | ||||
|       }) | ||||
|     } | ||||
|   }) | ||||
| 
 | ||||
|   const [deleteServerWalletLogs] = useMutation( | ||||
|     gql` | ||||
|       mutation deleteWalletLogs($wallet: String) { | ||||
|         deleteWalletLogs(wallet: $wallet) | ||||
|       } | ||||
|     `,
 | ||||
|     { | ||||
|       onCompleted: (_, { variables: { wallet: walletType } }) => { | ||||
|         setLogs((logs) => { | ||||
|           return logs.filter(l => walletType ? l.wallet !== getWalletByType(walletType).name : false) | ||||
|         }) | ||||
|       } | ||||
|     } | ||||
|   ) | ||||
| 
 | ||||
|   const saveLog = useCallback((log) => { | ||||
|     if (!idb.current) { | ||||
|       // IDB may not be ready yet
 | ||||
|       return logQueue.current.push(log) | ||||
|     } | ||||
|     const tx = idb.current.transaction(idbStoreName, 'readwrite') | ||||
|     const request = tx.objectStore(idbStoreName).add(log) | ||||
|     request.onerror = () => console.error('failed to save log:', log) | ||||
|   }, []) | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     initIndexedDB(dbName, idbStoreName) | ||||
|       .then(db => { | ||||
|         idb.current = db | ||||
| 
 | ||||
|         // load all logs from IDB
 | ||||
|         const tx = idb.current.transaction(idbStoreName, 'readonly') | ||||
|         const store = tx.objectStore(idbStoreName) | ||||
|         const index = store.index('ts') | ||||
|         const request = index.getAll() | ||||
|         request.onsuccess = () => { | ||||
|           let logs = request.result | ||||
|           setLogs((prevLogs) => { | ||||
|             if (process.env.NODE_ENV !== 'production') { | ||||
|               // in dev mode, useEffect runs twice, so we filter out duplicates here
 | ||||
|               const existingIds = prevLogs.map(({ id }) => id) | ||||
|               logs = logs.filter(({ id }) => !existingIds.includes(id)) | ||||
|             } | ||||
|             // sort oldest first to keep same order as logs are appended
 | ||||
|             return [...prevLogs, ...logs].sort((a, b) => b.ts - a.ts) | ||||
|           }) | ||||
|         } | ||||
| 
 | ||||
|         // flush queued logs to IDB
 | ||||
|         logQueue.current.forEach(q => { | ||||
|           const isLog = !!q.wallet | ||||
|           if (isLog) saveLog(q) | ||||
|         }) | ||||
| 
 | ||||
|         logQueue.current = [] | ||||
|       }) | ||||
|       .catch(console.error) | ||||
|     return () => idb.current?.close() | ||||
|   }, []) | ||||
| 
 | ||||
|   const appendLog = useCallback((wallet, level, message) => { | ||||
|     const log = { wallet: tag(wallet), level, message, ts: +new Date() } | ||||
|     saveLog(log) | ||||
|     setLogs((prevLogs) => [log, ...prevLogs]) | ||||
|   }, [saveLog]) | ||||
| 
 | ||||
|   const deleteLogs = useCallback(async (wallet) => { | ||||
|     if (!wallet || wallet.walletType) { | ||||
|       await deleteServerWalletLogs({ variables: { wallet: wallet?.walletType } }) | ||||
|     } | ||||
|     if (!wallet || wallet.sendPayment) { | ||||
|       const tx = idb.current.transaction(idbStoreName, 'readwrite') | ||||
|       const objectStore = tx.objectStore(idbStoreName) | ||||
|       const idx = objectStore.index('wallet_ts') | ||||
|       const request = wallet ? idx.openCursor(window.IDBKeyRange.bound([tag(wallet), -Infinity], [tag(wallet), Infinity])) : idx.openCursor() | ||||
|       request.onsuccess = function (event) { | ||||
|         const cursor = event.target.result | ||||
|         if (cursor) { | ||||
|           cursor.delete() | ||||
|           cursor.continue() | ||||
|         } else { | ||||
|           // finished
 | ||||
|           setLogs((logs) => logs.filter(l => wallet ? l.wallet !== tag(wallet) : false)) | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   }, [me, setLogs]) | ||||
| 
 | ||||
|   return ( | ||||
|     <WalletLogsContext.Provider value={logs}> | ||||
|       <WalletLoggerContext.Provider value={{ appendLog, deleteLogs }}> | ||||
|         {children} | ||||
|       </WalletLoggerContext.Provider> | ||||
|     </WalletLogsContext.Provider> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| export function useWalletLogger (wallet) { | ||||
|   const { appendLog, deleteLogs: innerDeleteLogs } = useContext(WalletLoggerContext) | ||||
| 
 | ||||
|   const log = useCallback(level => message => { | ||||
|     if (!wallet) { | ||||
|       console.error('cannot log: no wallet set') | ||||
|       return | ||||
|     } | ||||
| 
 | ||||
|     // don't store logs for receiving wallets on client since logs are stored on server
 | ||||
|     if (wallet.walletType) return | ||||
| 
 | ||||
|     // TODO:
 | ||||
|     //   also send this to us if diagnostics was enabled,
 | ||||
|     //   very similar to how the service worker logger works.
 | ||||
|     appendLog(wallet, level, message) | ||||
|     console[level !== 'error' ? 'info' : 'error'](`[${tag(wallet)}]`, message) | ||||
|   }, [appendLog, wallet]) | ||||
| 
 | ||||
|   const logger = useMemo(() => ({ | ||||
|     ok: (...message) => log('ok')(message.join(' ')), | ||||
|     info: (...message) => log('info')(message.join(' ')), | ||||
|     error: (...message) => log('error')(message.join(' ')) | ||||
|   }), [log, wallet?.name]) | ||||
| 
 | ||||
|   const deleteLogs = useCallback((w) => innerDeleteLogs(w || wallet), [innerDeleteLogs, wallet]) | ||||
| 
 | ||||
|   return { logger, deleteLogs } | ||||
| } | ||||
| 
 | ||||
| function tag (wallet) { | ||||
|   return wallet?.shortName || wallet?.name | ||||
| } | ||||
| 
 | ||||
| export function useWalletLogs (wallet) { | ||||
|   const logs = useContext(WalletLogsContext) | ||||
|   return logs.filter(l => !wallet || l.wallet === tag(wallet)) | ||||
| } | ||||
| @ -1,121 +0,0 @@ | ||||
| import { useRouter } from 'next/router' | ||||
| import LogMessage from './log-message' | ||||
| import { useWalletLogger, useWalletLogs } from './logger' | ||||
| import { useEffect, useRef, useState } from 'react' | ||||
| import { Checkbox, Form } from './form' | ||||
| import { useField } from 'formik' | ||||
| import styles from '@/styles/log.module.css' | ||||
| import { Button } from 'react-bootstrap' | ||||
| import { useToast } from './toast' | ||||
| import { useShowModal } from './modal' | ||||
| 
 | ||||
| const FollowCheckbox = ({ value, ...props }) => { | ||||
|   const [,, helpers] = useField(props.name) | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     helpers.setValue(value) | ||||
|   }, [value]) | ||||
| 
 | ||||
|   return ( | ||||
|     <Checkbox {...props} /> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| export default function WalletLogs ({ wallet, embedded }) { | ||||
|   const logs = useWalletLogs(wallet) | ||||
| 
 | ||||
|   const router = useRouter() | ||||
|   const { follow: defaultFollow } = router.query | ||||
|   const [follow, setFollow] = useState(defaultFollow ?? true) | ||||
|   const tableRef = useRef() | ||||
|   const scrollY = useRef() | ||||
|   const showModal = useShowModal() | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     if (follow) { | ||||
|       tableRef.current?.scroll({ top: tableRef.current.scrollHeight, behavior: 'smooth' }) | ||||
|     } | ||||
|   }, [logs, follow]) | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     function onScroll (e) { | ||||
|       const y = e.target.scrollTop | ||||
| 
 | ||||
|       const down = y - scrollY.current >= -1 | ||||
|       if (!!scrollY.current && !down) { | ||||
|         setFollow(false) | ||||
|       } | ||||
| 
 | ||||
|       const maxY = e.target.scrollHeight - e.target.clientHeight | ||||
|       const dY = maxY - y | ||||
|       const isBottom = dY >= -1 && dY <= 1 | ||||
|       if (isBottom) { | ||||
|         setFollow(true) | ||||
|       } | ||||
| 
 | ||||
|       scrollY.current = y | ||||
|     } | ||||
|     tableRef.current?.addEventListener('scroll', onScroll) | ||||
|     return () => tableRef.current?.removeEventListener('scroll', onScroll) | ||||
|   }, []) | ||||
| 
 | ||||
|   return ( | ||||
|     <> | ||||
|       <div className='d-flex w-100 align-items-center mb-3'> | ||||
|         <Form initial={{ follow: true }}> | ||||
|           <FollowCheckbox | ||||
|             label='follow logs' name='follow' value={follow} | ||||
|             handleChange={setFollow} groupClassName='mb-0' | ||||
|           /> | ||||
|         </Form> | ||||
|         <span | ||||
|           style={{ cursor: 'pointer' }} | ||||
|           className='text-muted fw-bold nav-link' onClick={() => { | ||||
|             showModal(onClose => <DeleteWalletLogsObstacle wallet={wallet} onClose={onClose} />) | ||||
|           }} | ||||
|         >clear | ||||
|         </span> | ||||
|       </div> | ||||
|       <div ref={tableRef} className={`${styles.logTable} ${embedded ? styles.embedded : ''}`}> | ||||
|         <div className='w-100 text-center'>------ start of logs ------</div> | ||||
|         {logs.length === 0 && <div className='w-100 text-center'>empty</div>} | ||||
|         <table> | ||||
|           <tbody> | ||||
|             {logs.map((log, i) => <LogMessage key={i} {...log} />)} | ||||
|           </tbody> | ||||
|         </table> | ||||
|       </div> | ||||
|     </> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| function DeleteWalletLogsObstacle ({ wallet, onClose }) { | ||||
|   const toaster = useToast() | ||||
|   const { deleteLogs } = useWalletLogger(wallet) | ||||
| 
 | ||||
|   const prompt = `Do you really want to delete all ${wallet ? '' : 'wallet'} logs ${wallet ? 'of this wallet' : ''}?` | ||||
|   return ( | ||||
|     <div className='text-center'> | ||||
|       {prompt} | ||||
|       <div className='d-flex justify-center align-items-center mt-3 mx-auto'> | ||||
|         <span style={{ cursor: 'pointer' }} className='d-flex ms-auto text-muted fw-bold nav-link mx-3' onClick={onClose}>cancel</span> | ||||
|         <Button | ||||
|           className='d-flex me-auto mx-3' variant='danger' | ||||
|           onClick={ | ||||
|             async () => { | ||||
|               try { | ||||
|                 await deleteLogs() | ||||
|                 onClose() | ||||
|                 toaster.success('deleted wallet logs') | ||||
|               } catch (err) { | ||||
|                 console.error(err) | ||||
|                 toaster.danger('failed to delete wallet logs') | ||||
|               } | ||||
|             } | ||||
|           } | ||||
|         >delete | ||||
|         </Button> | ||||
|       </div> | ||||
|     </div> | ||||
|   ) | ||||
| } | ||||
| @ -1,142 +0,0 @@ | ||||
| import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' | ||||
| import { LNbitsProvider, useLNbits } from './lnbits' | ||||
| import { NWCProvider, useNWC } from './nwc' | ||||
| import { LNCProvider, useLNC } from './lnc' | ||||
| 
 | ||||
| const WebLNContext = createContext({}) | ||||
| 
 | ||||
| const isEnabled = p => [Status.Enabled, Status.Locked].includes(p?.status) | ||||
| 
 | ||||
| const syncProvider = (array, provider) => { | ||||
|   const idx = array.findIndex(({ name }) => provider.name === name) | ||||
|   const enabled = isEnabled(provider) | ||||
|   if (idx === -1) { | ||||
|     // add provider to end if enabled
 | ||||
|     return enabled ? [...array, provider] : array | ||||
|   } | ||||
|   return [ | ||||
|     ...array.slice(0, idx), | ||||
|     // remove provider if not enabled
 | ||||
|     ...enabled ? [provider] : [], | ||||
|     ...array.slice(idx + 1) | ||||
|   ] | ||||
| } | ||||
| 
 | ||||
| const storageKey = 'webln:providers' | ||||
| 
 | ||||
| export const Status = { | ||||
|   Initialized: 'Initialized', | ||||
|   Enabled: 'Enabled', | ||||
|   Locked: 'Locked', | ||||
|   Error: 'Error' | ||||
| } | ||||
| 
 | ||||
| export function migrateLocalStorage (oldStorageKey, newStorageKey) { | ||||
|   const item = window.localStorage.getItem(oldStorageKey) | ||||
|   if (item) { | ||||
|     window.localStorage.setItem(newStorageKey, item) | ||||
|     window.localStorage.removeItem(oldStorageKey) | ||||
|   } | ||||
|   return item | ||||
| } | ||||
| 
 | ||||
| function RawWebLNProvider ({ children }) { | ||||
|   const lnbits = useLNbits() | ||||
|   const nwc = useNWC() | ||||
|   const lnc = useLNC() | ||||
|   const availableProviders = [lnbits, nwc, lnc] | ||||
|   const [enabledProviders, setEnabledProviders] = useState([]) | ||||
| 
 | ||||
|   // restore order on page reload
 | ||||
|   useEffect(() => { | ||||
|     const storedOrder = window.localStorage.getItem(storageKey) | ||||
|     if (!storedOrder) return | ||||
|     const providerNames = JSON.parse(storedOrder) | ||||
|     setEnabledProviders(providers => { | ||||
|       return providerNames.map(name => { | ||||
|         for (const p of availableProviders) { | ||||
|           if (p.name === name) return p | ||||
|         } | ||||
|         console.warn(`Stored provider with name ${name} not available`) | ||||
|         return null | ||||
|       }) | ||||
|     }) | ||||
|   }, []) | ||||
| 
 | ||||
|   // keep list in sync with underlying providers
 | ||||
|   useEffect(() => { | ||||
|     setEnabledProviders(providers => { | ||||
|       // Sync existing provider state with new provider state
 | ||||
|       // in the list while keeping the order they are in.
 | ||||
|       // If provider does not exist but is enabled, it is just added to the end of the list.
 | ||||
|       // This can be the case if we're syncing from a page reload
 | ||||
|       // where the providers are initially not enabled.
 | ||||
|       // If provider is no longer enabled, it is removed from the list.
 | ||||
|       const isInitialized = p => [Status.Enabled, Status.Locked, Status.Initialized].includes(p.status) | ||||
|       const newProviders = availableProviders.filter(isInitialized).reduce(syncProvider, providers) | ||||
|       const newOrder = newProviders.map(({ name }) => name) | ||||
|       window.localStorage.setItem(storageKey, JSON.stringify(newOrder)) | ||||
|       return newProviders | ||||
|     }) | ||||
|   }, [...availableProviders]) | ||||
| 
 | ||||
|   // first provider in list is the default provider
 | ||||
|   // TODO: implement fallbacks via provider priority
 | ||||
|   const provider = enabledProviders[0] | ||||
| 
 | ||||
|   const setProvider = useCallback((defaultProvider) => { | ||||
|     // move provider to the start to set it as default
 | ||||
|     setEnabledProviders(providers => { | ||||
|       const idx = providers.findIndex(({ name }) => defaultProvider.name === name) | ||||
|       if (idx === -1) { | ||||
|         console.warn(`tried to set unenabled provider ${defaultProvider.name} as default`) | ||||
|         return providers | ||||
|       } | ||||
|       return [defaultProvider, ...providers.slice(0, idx), ...providers.slice(idx + 1)] | ||||
|     }) | ||||
|   }, [setEnabledProviders]) | ||||
| 
 | ||||
|   const clearConfig = useCallback(async () => { | ||||
|     lnbits.clearConfig() | ||||
|     nwc.clearConfig() | ||||
|     await lnc.clearConfig() | ||||
|   }, []) | ||||
| 
 | ||||
|   const value = useMemo(() => ({ | ||||
|     provider: isEnabled(provider) | ||||
|       ? { name: provider.name, sendPayment: provider.sendPayment } | ||||
|       : null, | ||||
|     enabledProviders, | ||||
|     setProvider, | ||||
|     clearConfig | ||||
|   }), [provider, enabledProviders, setProvider]) | ||||
| 
 | ||||
|   return ( | ||||
|     <WebLNContext.Provider value={value}> | ||||
|       {children} | ||||
|     </WebLNContext.Provider> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| export function WebLNProvider ({ children }) { | ||||
|   return ( | ||||
|     <LNbitsProvider> | ||||
|       <NWCProvider> | ||||
|         <LNCProvider> | ||||
|           <RawWebLNProvider> | ||||
|             {children} | ||||
|           </RawWebLNProvider> | ||||
|         </LNCProvider> | ||||
|       </NWCProvider> | ||||
|     </LNbitsProvider> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| export function useWebLN () { | ||||
|   const { provider } = useContext(WebLNContext) | ||||
|   return provider | ||||
| } | ||||
| 
 | ||||
| export function useWebLNConfigurator () { | ||||
|   return useContext(WebLNContext) | ||||
| } | ||||
| @ -1,210 +0,0 @@ | ||||
| import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' | ||||
| import { useWalletLogger } from '../logger' | ||||
| import { Status, migrateLocalStorage } from '.' | ||||
| import { bolt11Tags } from '@/lib/bolt11' | ||||
| import { Wallet } from '@/lib/constants' | ||||
| import { useMe } from '../me' | ||||
| 
 | ||||
| // Reference: https://github.com/getAlby/bitcoin-connect/blob/v3.2.0-alpha/src/connectors/LnbitsConnector.ts
 | ||||
| 
 | ||||
| const LNbitsContext = createContext() | ||||
| 
 | ||||
| const getWallet = async (baseUrl, adminKey) => { | ||||
|   const url = baseUrl.replace(/\/+$/, '') | ||||
|   const path = '/api/v1/wallet' | ||||
| 
 | ||||
|   const headers = new Headers() | ||||
|   headers.append('Accept', 'application/json') | ||||
|   headers.append('Content-Type', 'application/json') | ||||
|   headers.append('X-Api-Key', adminKey) | ||||
| 
 | ||||
|   const res = await fetch(url + path, { method: 'GET', headers }) | ||||
|   if (!res.ok) { | ||||
|     const errBody = await res.json() | ||||
|     throw new Error(errBody.detail) | ||||
|   } | ||||
|   const wallet = await res.json() | ||||
|   return wallet | ||||
| } | ||||
| 
 | ||||
| const postPayment = async (baseUrl, adminKey, bolt11) => { | ||||
|   const url = baseUrl.replace(/\/+$/, '') | ||||
|   const path = '/api/v1/payments' | ||||
| 
 | ||||
|   const headers = new Headers() | ||||
|   headers.append('Accept', 'application/json') | ||||
|   headers.append('Content-Type', 'application/json') | ||||
|   headers.append('X-Api-Key', adminKey) | ||||
| 
 | ||||
|   const body = JSON.stringify({ bolt11, out: true }) | ||||
| 
 | ||||
|   const res = await fetch(url + path, { method: 'POST', headers, body }) | ||||
|   if (!res.ok) { | ||||
|     const errBody = await res.json() | ||||
|     throw new Error(errBody.detail) | ||||
|   } | ||||
|   const payment = await res.json() | ||||
|   return payment | ||||
| } | ||||
| 
 | ||||
| const getPayment = async (baseUrl, adminKey, paymentHash) => { | ||||
|   const url = baseUrl.replace(/\/+$/, '') | ||||
|   const path = `/api/v1/payments/${paymentHash}` | ||||
| 
 | ||||
|   const headers = new Headers() | ||||
|   headers.append('Accept', 'application/json') | ||||
|   headers.append('Content-Type', 'application/json') | ||||
|   headers.append('X-Api-Key', adminKey) | ||||
| 
 | ||||
|   const res = await fetch(url + path, { method: 'GET', headers }) | ||||
|   if (!res.ok) { | ||||
|     const errBody = await res.json() | ||||
|     throw new Error(errBody.detail) | ||||
|   } | ||||
|   const payment = await res.json() | ||||
|   return payment | ||||
| } | ||||
| 
 | ||||
| export function LNbitsProvider ({ children }) { | ||||
|   const me = useMe() | ||||
|   const [url, setUrl] = useState('') | ||||
|   const [adminKey, setAdminKey] = useState('') | ||||
|   const [status, setStatus] = useState() | ||||
|   const { logger } = useWalletLogger(Wallet.LNbits) | ||||
| 
 | ||||
|   let storageKey = 'webln:provider:lnbits' | ||||
|   if (me) { | ||||
|     storageKey = `${storageKey}:${me.id}` | ||||
|   } | ||||
| 
 | ||||
|   const getInfo = useCallback(async () => { | ||||
|     const response = await getWallet(url, adminKey) | ||||
|     return { | ||||
|       node: { | ||||
|         alias: response.name, | ||||
|         pubkey: '' | ||||
|       }, | ||||
|       methods: [ | ||||
|         'getInfo', | ||||
|         'getBalance', | ||||
|         'sendPayment' | ||||
|       ], | ||||
|       version: '1.0', | ||||
|       supports: ['lightning'] | ||||
|     } | ||||
|   }, [url, adminKey]) | ||||
| 
 | ||||
|   const sendPayment = useCallback(async (bolt11) => { | ||||
|     const hash = bolt11Tags(bolt11).payment_hash | ||||
|     logger.info('sending payment:', `payment_hash=${hash}`) | ||||
| 
 | ||||
|     try { | ||||
|       const response = await postPayment(url, adminKey, bolt11) | ||||
|       const checkResponse = await getPayment(url, adminKey, response.payment_hash) | ||||
|       if (!checkResponse.preimage) { | ||||
|         throw new Error('No preimage') | ||||
|       } | ||||
|       const preimage = checkResponse.preimage | ||||
|       logger.ok('payment successful:', `payment_hash=${hash}`, `preimage=${preimage}`) | ||||
|       return { preimage } | ||||
|     } catch (err) { | ||||
|       logger.error('payment failed:', `payment_hash=${hash}`, err.message || err.toString?.()) | ||||
|       throw err | ||||
|     } | ||||
|   }, [logger, url, adminKey]) | ||||
| 
 | ||||
|   const loadConfig = useCallback(async () => { | ||||
|     let configStr = window.localStorage.getItem(storageKey) | ||||
|     setStatus(Status.Initialized) | ||||
|     if (!configStr) { | ||||
|       if (me) { | ||||
|         // backwards compatibility: try old storageKey
 | ||||
|         const oldStorageKey = storageKey.split(':').slice(0, -1).join(':') | ||||
|         configStr = migrateLocalStorage(oldStorageKey, storageKey) | ||||
|       } | ||||
|       if (!configStr) { | ||||
|         logger.info('no existing config found') | ||||
|         return | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     const config = JSON.parse(configStr) | ||||
| 
 | ||||
|     const { url, adminKey } = config | ||||
|     setUrl(url) | ||||
|     setAdminKey(adminKey) | ||||
| 
 | ||||
|     logger.info( | ||||
|       'loaded wallet config: ' + | ||||
|       'adminKey=****** ' + | ||||
|       `url=${url}`) | ||||
| 
 | ||||
|     try { | ||||
|       // validate config by trying to fetch wallet
 | ||||
|       logger.info('trying to fetch wallet') | ||||
|       await getWallet(url, adminKey) | ||||
|       logger.ok('wallet found') | ||||
|       setStatus(Status.Enabled) | ||||
|       logger.ok('wallet enabled') | ||||
|     } catch (err) { | ||||
|       logger.error('invalid config:', err) | ||||
|       setStatus(Status.Error) | ||||
|       logger.info('wallet disabled') | ||||
|       throw err | ||||
|     } | ||||
|   }, [me, logger]) | ||||
| 
 | ||||
|   const saveConfig = useCallback(async (config) => { | ||||
|     // immediately store config so it's not lost even if config is invalid
 | ||||
|     setUrl(config.url) | ||||
|     setAdminKey(config.adminKey) | ||||
| 
 | ||||
|     // XXX This is insecure, XSS vulns could lead to loss of funds!
 | ||||
|     //   -> check how mutiny encrypts their wallet and/or check if we can leverage web workers
 | ||||
|     //   https://thenewstack.io/leveraging-web-workers-to-safely-store-access-tokens/
 | ||||
|     window.localStorage.setItem(storageKey, JSON.stringify(config)) | ||||
| 
 | ||||
|     logger.info( | ||||
|       'saved wallet config: ' + | ||||
|       'adminKey=****** ' + | ||||
|       `url=${config.url}`) | ||||
| 
 | ||||
|     try { | ||||
|       // validate config by trying to fetch wallet
 | ||||
|       logger.info('trying to fetch wallet') | ||||
|       await getWallet(config.url, config.adminKey) | ||||
|       logger.ok('wallet found') | ||||
|       setStatus(Status.Enabled) | ||||
|       logger.ok('wallet enabled') | ||||
|     } catch (err) { | ||||
|       logger.error('invalid config:', err) | ||||
|       setStatus(Status.Error) | ||||
|       logger.info('wallet disabled') | ||||
|       throw err | ||||
|     } | ||||
|   }, []) | ||||
| 
 | ||||
|   const clearConfig = useCallback(() => { | ||||
|     window.localStorage.removeItem(storageKey) | ||||
|     setUrl('') | ||||
|     setAdminKey('') | ||||
|     setStatus(undefined) | ||||
|   }, []) | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     loadConfig().catch(console.error) | ||||
|   }, []) | ||||
| 
 | ||||
|   const value = useMemo( | ||||
|     () => ({ name: 'LNbits', url, adminKey, status, saveConfig, clearConfig, getInfo, sendPayment }), | ||||
|     [url, adminKey, status, saveConfig, clearConfig, getInfo, sendPayment]) | ||||
|   return ( | ||||
|     <LNbitsContext.Provider value={value}> | ||||
|       {children} | ||||
|     </LNbitsContext.Provider> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| export function useLNbits () { | ||||
|   return useContext(LNbitsContext) | ||||
| } | ||||
| @ -1,215 +0,0 @@ | ||||
| import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' | ||||
| import { useWalletLogger } from '../logger' | ||||
| import { Status, migrateLocalStorage } from '.' | ||||
| import { bolt11Tags } from '@/lib/bolt11' | ||||
| import useModal from '../modal' | ||||
| import { Form, PasswordInput, SubmitButton } from '../form' | ||||
| import CancelButton from '../cancel-button' | ||||
| import { Mutex } from 'async-mutex' | ||||
| import { Wallet } from '@/lib/constants' | ||||
| import { useMe } from '../me' | ||||
| import { InvoiceCanceledError, InvoiceExpiredError } from '@/components/payment' | ||||
| 
 | ||||
| const LNCContext = createContext() | ||||
| const mutex = new Mutex() | ||||
| 
 | ||||
| async function getLNC ({ me }) { | ||||
|   if (window.lnc) return window.lnc | ||||
|   const { default: LNC } = await import('@lightninglabs/lnc-web') | ||||
|   // backwards compatibility: migrate to new storage key
 | ||||
|   if (me) migrateLocalStorage('lnc-web:default', `lnc-web:stacker:${me.id}`) | ||||
|   window.lnc = new LNC({ namespace: me?.id ? `stacker:${me.id}` : undefined }) | ||||
|   return window.lnc | ||||
| } | ||||
| 
 | ||||
| // default password if the user hasn't set one
 | ||||
| export const XXX_DEFAULT_PASSWORD = 'password' | ||||
| 
 | ||||
| function validateNarrowPerms (lnc) { | ||||
|   if (!lnc.hasPerms('lnrpc.Lightning.SendPaymentSync')) { | ||||
|     throw new Error('missing permission: lnrpc.Lightning.SendPaymentSync') | ||||
|   } | ||||
|   if (lnc.hasPerms('lnrpc.Lightning.SendCoins')) { | ||||
|     throw new Error('too broad permission: lnrpc.Wallet.SendCoins') | ||||
|   } | ||||
|   // TODO: need to check for more narrow permissions
 | ||||
|   // blocked by https://github.com/lightninglabs/lnc-web/issues/112
 | ||||
| } | ||||
| 
 | ||||
| export function LNCProvider ({ children }) { | ||||
|   const me = useMe() | ||||
|   const { logger } = useWalletLogger(Wallet.LNC) | ||||
|   const [config, setConfig] = useState({}) | ||||
|   const [lnc, setLNC] = useState() | ||||
|   const [status, setStatus] = useState() | ||||
|   const [modal, showModal] = useModal() | ||||
| 
 | ||||
|   const getInfo = useCallback(async () => { | ||||
|     logger.info('getInfo called') | ||||
|     return await lnc.lightning.getInfo() | ||||
|   }, [logger, lnc]) | ||||
| 
 | ||||
|   const unlock = useCallback(async (connect) => { | ||||
|     if (status === Status.Enabled) return config.password | ||||
| 
 | ||||
|     return await new Promise((resolve, reject) => { | ||||
|       const cancelAndReject = async () => { | ||||
|         reject(new Error('password canceled')) | ||||
|       } | ||||
|       showModal(onClose => { | ||||
|         return ( | ||||
|           <Form | ||||
|             initial={{ | ||||
|               password: '' | ||||
|             }} | ||||
|             onSubmit={async (values) => { | ||||
|               try { | ||||
|                 lnc.credentials.password = values?.password | ||||
|                 setStatus(Status.Enabled) | ||||
|                 setConfig({ pairingPhrase: lnc.credentials.pairingPhrase, password: values.password }) | ||||
|                 logger.ok('wallet enabled') | ||||
|                 onClose() | ||||
|                 resolve(values.password) | ||||
|               } catch (err) { | ||||
|                 logger.error('failed attempt to unlock wallet', err) | ||||
|                 throw err | ||||
|               } | ||||
|             }} | ||||
|           > | ||||
|             <h4 className='text-center mb-3'>Unlock LNC</h4> | ||||
|             <PasswordInput | ||||
|               label='password' | ||||
|               name='password' | ||||
|             /> | ||||
|             <div className='mt-5 d-flex justify-content-between'> | ||||
|               <CancelButton onClick={() => { onClose(); cancelAndReject() }} /> | ||||
|               <SubmitButton variant='primary'>unlock</SubmitButton> | ||||
|             </div> | ||||
|           </Form> | ||||
|         ) | ||||
|       }, { onClose: cancelAndReject }) | ||||
|     }) | ||||
|   }, [logger, showModal, setConfig, lnc, status]) | ||||
| 
 | ||||
|   const sendPayment = useCallback(async (bolt11) => { | ||||
|     const hash = bolt11Tags(bolt11).payment_hash | ||||
|     logger.info('sending payment:', `payment_hash=${hash}`) | ||||
| 
 | ||||
|     return await mutex.runExclusive(async () => { | ||||
|       try { | ||||
|         const password = await unlock() | ||||
|         // credentials need to be decrypted before connecting after a disconnect
 | ||||
|         lnc.credentials.password = password || XXX_DEFAULT_PASSWORD | ||||
|         await lnc.connect() | ||||
|         const { paymentError, paymentPreimage: preimage } = | ||||
|           await lnc.lnd.lightning.sendPaymentSync({ payment_request: bolt11 }) | ||||
| 
 | ||||
|         if (paymentError) throw new Error(paymentError) | ||||
|         if (!preimage) throw new Error('No preimage in response') | ||||
| 
 | ||||
|         logger.ok('payment successful:', `payment_hash=${hash}`, `preimage=${preimage}`) | ||||
|         return { preimage } | ||||
|       } catch (err) { | ||||
|         const msg = err.message || err.toString?.() | ||||
|         logger.error('payment failed:', `payment_hash=${hash}`, msg) | ||||
|         if (msg.includes('invoice expired')) { | ||||
|           throw new InvoiceExpiredError(hash) | ||||
|         } | ||||
|         if (msg.includes('canceled')) { | ||||
|           throw new InvoiceCanceledError(hash) | ||||
|         } | ||||
|         throw err | ||||
|       } finally { | ||||
|         try { | ||||
|           lnc.disconnect() | ||||
|           logger.info('disconnecting after:', `payment_hash=${hash}`) | ||||
|           // wait for lnc to disconnect before releasing the mutex
 | ||||
|           await new Promise((resolve, reject) => { | ||||
|             let counter = 0 | ||||
|             const interval = setInterval(() => { | ||||
|               if (lnc.isConnected) { | ||||
|                 if (counter++ > 100) { | ||||
|                   logger.error('failed to disconnect from lnc') | ||||
|                   clearInterval(interval) | ||||
|                   reject(new Error('failed to disconnect from lnc')) | ||||
|                 } | ||||
|                 return | ||||
|               } | ||||
|               clearInterval(interval) | ||||
|               resolve() | ||||
|             }) | ||||
|           }, 50) | ||||
|         } catch (err) { | ||||
|           logger.error('failed to disconnect from lnc', err) | ||||
|         } | ||||
|       } | ||||
|     }) | ||||
|   }, [logger, lnc, unlock]) | ||||
| 
 | ||||
|   const saveConfig = useCallback(async config => { | ||||
|     setConfig(config) | ||||
| 
 | ||||
|     try { | ||||
|       lnc.credentials.pairingPhrase = config.pairingPhrase | ||||
|       await lnc.connect() | ||||
|       await validateNarrowPerms(lnc) | ||||
|       lnc.credentials.password = config?.password || XXX_DEFAULT_PASSWORD | ||||
|       setStatus(Status.Enabled) | ||||
|       logger.ok('wallet enabled') | ||||
|     } catch (err) { | ||||
|       logger.error('invalid config:', err) | ||||
|       setStatus(Status.Error) | ||||
|       logger.info('wallet disabled') | ||||
|       throw err | ||||
|     } finally { | ||||
|       lnc.disconnect() | ||||
|     } | ||||
|   }, [logger, lnc]) | ||||
| 
 | ||||
|   const clearConfig = useCallback(async () => { | ||||
|     await lnc.credentials.clear(false) | ||||
|     if (lnc.isConnected) lnc.disconnect() | ||||
|     setStatus(undefined) | ||||
|     setConfig({}) | ||||
|     logger.info('cleared config') | ||||
|   }, [logger, lnc]) | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     (async () => { | ||||
|       try { | ||||
|         const lnc = await getLNC({ me }) | ||||
|         setLNC(lnc) | ||||
|         setStatus(Status.Initialized) | ||||
|         if (lnc.credentials.isPaired) { | ||||
|           try { | ||||
|             // try the default password
 | ||||
|             lnc.credentials.password = XXX_DEFAULT_PASSWORD | ||||
|           } catch (err) { | ||||
|             setStatus(Status.Locked) | ||||
|             logger.info('wallet needs password before enabling') | ||||
|             return | ||||
|           } | ||||
|           setStatus(Status.Enabled) | ||||
|           setConfig({ pairingPhrase: lnc.credentials.pairingPhrase, password: lnc.credentials.password }) | ||||
|         } | ||||
|       } catch (err) { | ||||
|         logger.error('wallet could not be loaded:', err) | ||||
|         setStatus(Status.Error) | ||||
|       } | ||||
|     })() | ||||
|   }, [me, setStatus, setConfig, logger]) | ||||
| 
 | ||||
|   const value = useMemo( | ||||
|     () => ({ name: 'lnc', status, unlock, getInfo, sendPayment, config, saveConfig, clearConfig }), | ||||
|     [status, unlock, getInfo, sendPayment, config, saveConfig, clearConfig]) | ||||
|   return ( | ||||
|     <LNCContext.Provider value={value}> | ||||
|       {children} | ||||
|       {modal} | ||||
|     </LNCContext.Provider> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| export function useLNC () { | ||||
|   return useContext(LNCContext) | ||||
| } | ||||
| @ -1,287 +0,0 @@ | ||||
| // https://github.com/getAlby/js-sdk/blob/master/src/webln/NostrWeblnProvider.ts
 | ||||
| 
 | ||||
| import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' | ||||
| import { Relay, finalizeEvent, nip04 } from 'nostr-tools' | ||||
| import { parseNwcUrl } from '@/lib/url' | ||||
| import { useWalletLogger } from '../logger' | ||||
| import { Status, migrateLocalStorage } from '.' | ||||
| import { bolt11Tags } from '@/lib/bolt11' | ||||
| import { JIT_INVOICE_TIMEOUT_MS, Wallet } from '@/lib/constants' | ||||
| import { useMe } from '../me' | ||||
| import { InvoiceExpiredError } from '../payment' | ||||
| 
 | ||||
| const NWCContext = createContext() | ||||
| 
 | ||||
| export function NWCProvider ({ children }) { | ||||
|   const me = useMe() | ||||
|   const [nwcUrl, setNwcUrl] = useState('') | ||||
|   const [walletPubkey, setWalletPubkey] = useState() | ||||
|   const [relayUrl, setRelayUrl] = useState() | ||||
|   const [secret, setSecret] = useState() | ||||
|   const [status, setStatus] = useState() | ||||
|   const { logger } = useWalletLogger(Wallet.NWC) | ||||
| 
 | ||||
|   let storageKey = 'webln:provider:nwc' | ||||
|   if (me) { | ||||
|     storageKey = `${storageKey}:${me.id}` | ||||
|   } | ||||
| 
 | ||||
|   const getInfo = useCallback(async (relayUrl, walletPubkey) => { | ||||
|     logger.info(`requesting info event from ${relayUrl}`) | ||||
| 
 | ||||
|     let relay | ||||
|     try { | ||||
|       relay = await Relay.connect(relayUrl) | ||||
|       logger.ok(`connected to ${relayUrl}`) | ||||
|     } catch (err) { | ||||
|       const msg = `failed to connect to ${relayUrl}` | ||||
|       logger.error(msg) | ||||
|       throw new Error(msg) | ||||
|     } | ||||
| 
 | ||||
|     try { | ||||
|       return await new Promise((resolve, reject) => { | ||||
|         const timeout = 5000 | ||||
|         const timer = setTimeout(() => { | ||||
|           const msg = 'timeout waiting for info event' | ||||
|           logger.error(msg) | ||||
|           reject(new Error(msg)) | ||||
|         }, timeout) | ||||
| 
 | ||||
|         let found = false | ||||
|         relay.subscribe([ | ||||
|           { | ||||
|             kinds: [13194], | ||||
|             authors: [walletPubkey] | ||||
|           } | ||||
|         ], { | ||||
|           onevent (event) { | ||||
|             clearTimeout(timer) | ||||
|             found = true | ||||
|             logger.ok(`received info event from ${relayUrl}`) | ||||
|             resolve(event) | ||||
|           }, | ||||
|           onclose (reason) { | ||||
|             clearTimeout(timer) | ||||
|             if (!['closed by caller', 'relay connection closed by us'].includes(reason)) { | ||||
|               // only log if not closed by us (caller)
 | ||||
|               const msg = 'connection closed: ' + (reason || 'unknown reason') | ||||
|               logger.error(msg) | ||||
|               reject(new Error(msg)) | ||||
|             } | ||||
|           }, | ||||
|           oneose () { | ||||
|             clearTimeout(timer) | ||||
|             if (!found) { | ||||
|               const msg = 'EOSE received without info event' | ||||
|               logger.error(msg) | ||||
|               reject(new Error(msg)) | ||||
|             } | ||||
|           } | ||||
|         }) | ||||
|       }) | ||||
|     } finally { | ||||
|       relay?.close()?.catch() | ||||
|       if (relay) logger.info(`closed connection to ${relayUrl}`) | ||||
|     } | ||||
|   }, [logger]) | ||||
| 
 | ||||
|   const validateParams = useCallback(async ({ relayUrl, walletPubkey }) => { | ||||
|     // validate connection by fetching info event
 | ||||
|     // function needs to throw an error for formik validation to fail
 | ||||
|     const event = await getInfo(relayUrl, walletPubkey) | ||||
|     const supported = event.content.split(/[\s,]+/) // handle both spaces and commas
 | ||||
|     logger.info('supported methods:', supported) | ||||
|     if (!supported.includes('pay_invoice')) { | ||||
|       const msg = 'wallet does not support pay_invoice' | ||||
|       logger.error(msg) | ||||
|       throw new Error(msg) | ||||
|     } | ||||
|     logger.ok('wallet supports pay_invoice') | ||||
|   }, [logger]) | ||||
| 
 | ||||
|   const loadConfig = useCallback(async () => { | ||||
|     let configStr = window.localStorage.getItem(storageKey) | ||||
|     setStatus(Status.Initialized) | ||||
|     if (!configStr) { | ||||
|       if (me) { | ||||
|         // backwards compatibility: try old storageKey
 | ||||
|         const oldStorageKey = storageKey.split(':').slice(0, -1).join(':') | ||||
|         configStr = migrateLocalStorage(oldStorageKey, storageKey) | ||||
|       } | ||||
|       if (!configStr) { | ||||
|         logger.info('no existing config found') | ||||
|         return | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     const config = JSON.parse(configStr) | ||||
| 
 | ||||
|     const { nwcUrl } = config | ||||
|     setNwcUrl(nwcUrl) | ||||
| 
 | ||||
|     const params = parseNwcUrl(nwcUrl) | ||||
|     setRelayUrl(params.relayUrl) | ||||
|     setWalletPubkey(params.walletPubkey) | ||||
|     setSecret(params.secret) | ||||
| 
 | ||||
|     logger.info( | ||||
|       'loaded wallet config: ' + | ||||
|       'secret=****** ' + | ||||
|       `pubkey=${params.walletPubkey.slice(0, 6)}..${params.walletPubkey.slice(-6)} ` + | ||||
|       `relay=${params.relayUrl}`) | ||||
| 
 | ||||
|     try { | ||||
|       await validateParams(params) | ||||
|       setStatus(Status.Enabled) | ||||
|       logger.ok('wallet enabled') | ||||
|     } catch (err) { | ||||
|       logger.error('invalid config:', err) | ||||
|       setStatus(Status.Error) | ||||
|       logger.info('wallet disabled') | ||||
|       throw err | ||||
|     } | ||||
|   }, [me, validateParams, logger]) | ||||
| 
 | ||||
|   const saveConfig = useCallback(async (config) => { | ||||
|     // immediately store config so it's not lost even if config is invalid
 | ||||
|     const { nwcUrl } = config | ||||
|     setNwcUrl(nwcUrl) | ||||
|     if (!nwcUrl) { | ||||
|       setStatus(undefined) | ||||
|       return | ||||
|     } | ||||
| 
 | ||||
|     const params = parseNwcUrl(nwcUrl) | ||||
|     setRelayUrl(params.relayUrl) | ||||
|     setWalletPubkey(params.walletPubkey) | ||||
|     setSecret(params.secret) | ||||
| 
 | ||||
|     // XXX Even though NWC allows to configure budget,
 | ||||
|     // this is definitely not ideal from a security perspective.
 | ||||
|     window.localStorage.setItem(storageKey, JSON.stringify(config)) | ||||
| 
 | ||||
|     logger.info( | ||||
|       'saved wallet config: ' + | ||||
|       'secret=****** ' + | ||||
|       `pubkey=${params.walletPubkey.slice(0, 6)}..${params.walletPubkey.slice(-6)} ` + | ||||
|       `relay=${params.relayUrl}`) | ||||
| 
 | ||||
|     try { | ||||
|       await validateParams(params) | ||||
|       setStatus(Status.Enabled) | ||||
|       logger.ok('wallet enabled') | ||||
|     } catch (err) { | ||||
|       logger.error('invalid config:', err) | ||||
|       setStatus(Status.Error) | ||||
|       logger.info('wallet disabled') | ||||
|       throw err | ||||
|     } | ||||
|   }, [validateParams, logger]) | ||||
| 
 | ||||
|   const clearConfig = useCallback(() => { | ||||
|     window.localStorage.removeItem(storageKey) | ||||
|     setNwcUrl('') | ||||
|     setRelayUrl(undefined) | ||||
|     setWalletPubkey(undefined) | ||||
|     setSecret(undefined) | ||||
|     setStatus(undefined) | ||||
|   }, []) | ||||
| 
 | ||||
|   const sendPayment = useCallback(async (bolt11) => { | ||||
|     const hash = bolt11Tags(bolt11).payment_hash | ||||
|     logger.info('sending payment:', `payment_hash=${hash}`) | ||||
| 
 | ||||
|     let relay | ||||
|     try { | ||||
|       relay = await Relay.connect(relayUrl) | ||||
|       logger.ok(`connected to ${relayUrl}`) | ||||
|     } catch (err) { | ||||
|       const msg = `failed to connect to ${relayUrl}` | ||||
|       logger.error(msg) | ||||
|       throw new Error(msg) | ||||
|     } | ||||
| 
 | ||||
|     try { | ||||
|       const ret = await new Promise(function (resolve, reject) { | ||||
|         (async function () { | ||||
|           // timeout since NWC is async (user needs to confirm payment in wallet)
 | ||||
|           // timeout is same as invoice expiry
 | ||||
|           const timeout = JIT_INVOICE_TIMEOUT_MS | ||||
|           const timer = setTimeout(() => { | ||||
|             const msg = 'timeout waiting for payment' | ||||
|             logger.error(msg) | ||||
|             reject(new InvoiceExpiredError(hash)) | ||||
|           }, timeout) | ||||
| 
 | ||||
|           const payload = { | ||||
|             method: 'pay_invoice', | ||||
|             params: { invoice: bolt11 } | ||||
|           } | ||||
|           const content = await nip04.encrypt(secret, walletPubkey, JSON.stringify(payload)) | ||||
| 
 | ||||
|           const request = finalizeEvent({ | ||||
|             kind: 23194, | ||||
|             created_at: Math.floor(Date.now() / 1000), | ||||
|             tags: [['p', walletPubkey]], | ||||
|             content | ||||
|           }, secret) | ||||
|           await relay.publish(request) | ||||
| 
 | ||||
|           const filter = { | ||||
|             kinds: [23195], | ||||
|             authors: [walletPubkey], | ||||
|             '#e': [request.id] | ||||
|           } | ||||
|           relay.subscribe([filter], { | ||||
|             async onevent (response) { | ||||
|               clearTimeout(timer) | ||||
|               try { | ||||
|                 const content = JSON.parse(await nip04.decrypt(secret, walletPubkey, response.content)) | ||||
|                 if (content.error) return reject(new Error(content.error.message)) | ||||
|                 if (content.result) return resolve({ preimage: content.result.preimage }) | ||||
|               } catch (err) { | ||||
|                 return reject(err) | ||||
|               } | ||||
|             }, | ||||
|             onclose (reason) { | ||||
|               clearTimeout(timer) | ||||
|               if (!['closed by caller', 'relay connection closed by us'].includes(reason)) { | ||||
|                 // only log if not closed by us (caller)
 | ||||
|                 const msg = 'connection closed: ' + (reason || 'unknown reason') | ||||
|                 logger.error(msg) | ||||
|                 reject(new Error(msg)) | ||||
|               } | ||||
|             } | ||||
|           }) | ||||
|         })().catch(reject) | ||||
|       }) | ||||
|       const preimage = ret.preimage | ||||
|       logger.ok('payment successful:', `payment_hash=${hash}`, `preimage=${preimage}`) | ||||
|       return ret | ||||
|     } catch (err) { | ||||
|       logger.error('payment failed:', `payment_hash=${hash}`, err.message || err.toString?.()) | ||||
|       throw err | ||||
|     } finally { | ||||
|       relay?.close()?.catch() | ||||
|       if (relay) logger.info(`closed connection to ${relayUrl}`) | ||||
|     } | ||||
|   }, [walletPubkey, relayUrl, secret, logger]) | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     loadConfig().catch(err => logger.error(err.message || err.toString?.())) | ||||
|   }, []) | ||||
| 
 | ||||
|   const value = useMemo( | ||||
|     () => ({ name: 'NWC', nwcUrl, relayUrl, walletPubkey, secret, status, saveConfig, clearConfig, getInfo, sendPayment }), | ||||
|     [nwcUrl, relayUrl, walletPubkey, secret, status, saveConfig, clearConfig, getInfo, sendPayment]) | ||||
|   return ( | ||||
|     <NWCContext.Provider value={value}> | ||||
|       {children} | ||||
|     </NWCContext.Provider> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| export function useNWC () { | ||||
|   return useContext(NWCContext) | ||||
| } | ||||
| @ -100,27 +100,6 @@ export const SEND_TO_LNADDR = gql` | ||||
|     } | ||||
| }` | ||||
| 
 | ||||
| export const UPSERT_WALLET_LNADDR = | ||||
| gql` | ||||
| mutation upsertWalletLNAddr($id: ID, $address: String!, $settings: AutowithdrawSettings!) { | ||||
|   upsertWalletLNAddr(id: $id, address: $address, settings: $settings) | ||||
| } | ||||
| ` | ||||
| 
 | ||||
| export const UPSERT_WALLET_LND = | ||||
| gql` | ||||
| mutation upsertWalletLND($id: ID, $socket: String!, $macaroon: String!, $cert: String, $settings: AutowithdrawSettings!) { | ||||
|   upsertWalletLND(id: $id, socket: $socket, macaroon: $macaroon, cert: $cert, settings: $settings) | ||||
| } | ||||
| ` | ||||
| 
 | ||||
| export const UPSERT_WALLET_CLN = | ||||
| gql` | ||||
| mutation upsertWalletCLN($id: ID, $socket: String!, $rune: String!, $cert: String, $settings: AutowithdrawSettings!) { | ||||
|   upsertWalletCLN(id: $id, socket: $socket, rune: $rune, cert: $cert, settings: $settings) | ||||
| } | ||||
| ` | ||||
| 
 | ||||
| export const REMOVE_WALLET = | ||||
| 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 [] | ||||
|  | ||||
							
								
								
									
										133
									
								
								lib/validate.js
									
									
									
									
									
								
							
							
						
						
									
										133
									
								
								lib/validate.js
									
									
									
									
									
								
							| @ -6,13 +6,10 @@ import { | ||||
| } from './constants' | ||||
| import { SUPPORTED_CURRENCIES } from './currency' | ||||
| import { NOSTR_MAX_RELAY_NUM, NOSTR_PUBKEY_BECH32, NOSTR_PUBKEY_HEX } from './nostr' | ||||
| import { msatsToSats, numWithUnits, abbrNum, ensureB64, B64_URL_REGEX } from './format' | ||||
| import { msatsToSats, numWithUnits, abbrNum, ensureB64 } from './format' | ||||
| import * as usersFragments from '@/fragments/users' | ||||
| import * as subsFragments from '@/fragments/subs' | ||||
| import { isInvoicableMacaroon, isInvoiceMacaroon } from './macaroon' | ||||
| import { TOR_REGEXP, parseNwcUrl } from './url' | ||||
| import { datePivot } from './time' | ||||
| import { decodeRune } from '@/lib/cln' | ||||
| import bip39Words from './bip39-words' | ||||
| 
 | ||||
| const { SUB } = subsFragments | ||||
| @ -151,13 +148,13 @@ const nameValidator = string() | ||||
| const intValidator = number().typeError('must be a number').integer('must be whole') | ||||
| const floatValidator = number().typeError('must be a number') | ||||
| 
 | ||||
| const lightningAddressValidator = process.env.NODE_ENV === 'development' | ||||
| export const lightningAddressValidator = process.env.NODE_ENV === 'development' | ||||
|   ? string().or( | ||||
|     [string().matches(/^[\w_]+@localhost:\d+$/), string().email()], | ||||
|     'address is no good') | ||||
|   : string().email('address is no good') | ||||
| 
 | ||||
| const hexOrBase64Validator = string().test({ | ||||
| export const hexOrBase64Validator = string().test({ | ||||
|   name: 'hex-or-base64', | ||||
|   message: 'invalid encoding', | ||||
|   test: (val) => { | ||||
| @ -305,62 +302,10 @@ export function advSchema (args) { | ||||
|   }) | ||||
| } | ||||
| 
 | ||||
| export function lnAddrAutowithdrawSchema ({ me } = {}) { | ||||
|   return object({ | ||||
|     address: lightningAddressValidator.required('required').test({ | ||||
|       name: 'address', | ||||
|       test: addr => !addr.endsWith('@stacker.news'), | ||||
|       message: 'automated withdrawals must be external' | ||||
|     }), | ||||
|     ...autowithdrawSchemaMembers({ me }) | ||||
|   }) | ||||
| } | ||||
| 
 | ||||
| export function LNDAutowithdrawSchema ({ me } = {}) { | ||||
|   return object({ | ||||
|     socket: string().socket().required('required'), | ||||
|     macaroon: hexOrBase64Validator.required('required').test({ | ||||
|       name: 'macaroon', | ||||
|       test: v => isInvoiceMacaroon(v) || isInvoicableMacaroon(v), | ||||
|       message: 'not an invoice macaroon or an invoicable macaroon' | ||||
|     }), | ||||
|     cert: hexOrBase64Validator, | ||||
|     ...autowithdrawSchemaMembers({ me }) | ||||
|   }) | ||||
| } | ||||
| 
 | ||||
| export function CLNAutowithdrawSchema ({ me } = {}) { | ||||
|   return object({ | ||||
|     socket: string().socket().required('required'), | ||||
|     rune: string().matches(B64_URL_REGEX, { message: 'invalid rune' }).required('required') | ||||
|       .test({ | ||||
|         name: 'rune', | ||||
|         test: (v, context) => { | ||||
|           const decoded = decodeRune(v) | ||||
|           if (!decoded) return context.createError({ message: 'invalid rune' }) | ||||
|           if (decoded.restrictions.length === 0) { | ||||
|             return context.createError({ message: 'rune must be restricted to method=invoice' }) | ||||
|           } | ||||
|           if (decoded.restrictions.length !== 1 || decoded.restrictions[0].alternatives.length !== 1) { | ||||
|             return context.createError({ message: 'rune must be restricted to method=invoice only' }) | ||||
|           } | ||||
|           if (decoded.restrictions[0].alternatives[0] !== 'method=invoice') { | ||||
|             return context.createError({ message: 'rune must be restricted to method=invoice only' }) | ||||
|           } | ||||
|           return true | ||||
|         } | ||||
|       }), | ||||
|     cert: hexOrBase64Validator, | ||||
|     ...autowithdrawSchemaMembers({ me }) | ||||
|   }) | ||||
| } | ||||
| 
 | ||||
| export function autowithdrawSchemaMembers ({ me } = {}) { | ||||
|   return { | ||||
|     priority: boolean(), | ||||
|     autoWithdrawThreshold: intValidator.required('required').min(0, 'must be at least 0').max(msatsToSats(BALANCE_LIMIT_MSATS), `must be at most ${abbrNum(msatsToSats(BALANCE_LIMIT_MSATS))}`), | ||||
|     autoWithdrawMaxFeePercent: floatValidator.required('required').min(0, 'must be at least 0').max(50, 'must not exceed 50') | ||||
|   } | ||||
| export 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 function bountySchema (args) { | ||||
| @ -600,66 +545,24 @@ export const lnAddrSchema = ({ payerData, min, max, commentAllowed } = {}) => | ||||
|     return accum | ||||
|   }, {}))) | ||||
| 
 | ||||
| export const lnbitsSchema = object({ | ||||
|   url: process.env.NODE_ENV === 'development' | ||||
|     ? string() | ||||
|       .or([string().matches(/^(http:\/\/)?localhost:\d+$/), string().url()], 'invalid url') | ||||
|       .required('required').trim() | ||||
|     : string().url().required('required').trim() | ||||
|       .test(async (url, context) => { | ||||
|         if (TOR_REGEXP.test(url)) { | ||||
|           // allow HTTP and HTTPS over Tor
 | ||||
|           if (!/^https?:\/\//.test(url)) { | ||||
|             return context.createError({ message: 'http or https required' }) | ||||
|           } | ||||
|           return true | ||||
|         } | ||||
|         try { | ||||
|           // force HTTPS over clearnet
 | ||||
|           await string().https().validate(url) | ||||
|         } catch (err) { | ||||
|           return context.createError({ message: err.message }) | ||||
|         } | ||||
|         return true | ||||
|       }), | ||||
|   adminKey: string().length(32) | ||||
| }) | ||||
| 
 | ||||
| export const nwcSchema = object({ | ||||
|   nwcUrl: string() | ||||
|     .required('required') | ||||
|     .test(async (nwcUrl, context) => { | ||||
|       // run validation in sequence to control order of errors
 | ||||
|       // inspired by https://github.com/jquense/yup/issues/851#issuecomment-1049705180
 | ||||
|       try { | ||||
|         await string().required('required').validate(nwcUrl) | ||||
|         await string().matches(/^nostr\+?walletconnect:\/\//, { message: 'must start with nostr+walletconnect://' }).validate(nwcUrl) | ||||
|         let relayUrl, walletPubkey, secret | ||||
|         try { | ||||
|           ({ relayUrl, walletPubkey, secret } = parseNwcUrl(nwcUrl)) | ||||
|         } catch { | ||||
|           // invalid URL error. handle as if pubkey validation failed to not confuse user.
 | ||||
|           throw new Error('pubkey must be 64 hex chars') | ||||
|         } | ||||
|         await string().required('pubkey required').trim().matches(NOSTR_PUBKEY_HEX, 'pubkey must be 64 hex chars').validate(walletPubkey) | ||||
|         await string().required('relay url required').trim().wss('relay must use wss://').validate(relayUrl) | ||||
|         await string().required('secret required').trim().matches(/^[0-9a-fA-F]{64}$/, 'secret must be 64 hex chars').validate(secret) | ||||
|       } catch (err) { | ||||
|         return context.createError({ message: err.message }) | ||||
|       } | ||||
|       return true | ||||
|     }) | ||||
| }) | ||||
| 
 | ||||
| export const lncSchema = object({ | ||||
|   pairingPhrase: array() | ||||
|     .transform(function (value, originalValue) { | ||||
|       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'), | ||||
|  | ||||
							
								
								
									
										106
									
								
								lib/wallet.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										106
									
								
								lib/wallet.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,106 @@ | ||||
| import { array, object, string } from 'yup' | ||||
| import { autowithdrawSchemaMembers, hexOrBase64Validator, lightningAddressValidator } from '@/lib/validate' | ||||
| import { TOR_REGEXP } from '@/lib/url' | ||||
| import { B64_URL_REGEX } from '@/lib/format' | ||||
| 
 | ||||
| export function generateResolverName (walletField) { | ||||
|   const capitalized = walletField[0].toUpperCase() + walletField.slice(1) | ||||
|   return `upsertWallet${capitalized}` | ||||
| } | ||||
| 
 | ||||
| export function generateSchema (wallet) { | ||||
|   if (wallet.schema) return wallet.schema | ||||
| 
 | ||||
|   const fieldValidator = (field) => { | ||||
|     if (!field.validate) { | ||||
|       // default validation
 | ||||
|       let validator = string() | ||||
|       if (!field.optional) validator = validator.required('required') | ||||
|       return validator | ||||
|     } | ||||
| 
 | ||||
|     const { type: validationType, words, min, max } = field.validate | ||||
| 
 | ||||
|     let validator | ||||
| 
 | ||||
|     if (validationType === 'string') validator = string() | ||||
| 
 | ||||
|     if (validationType === 'url') { | ||||
|       validator = process.env.NODE_ENV === 'development' | ||||
|         ? string() | ||||
|           .or([string().matches(/^(http:\/\/)?localhost:\d+$/), string().url()], 'invalid url') | ||||
|         : string() | ||||
|           .url() | ||||
|           .test(async (url, context) => { | ||||
|             if (field.validate.torAllowed && TOR_REGEXP.test(url)) { | ||||
|               // allow HTTP and HTTPS over Tor
 | ||||
|               if (!/^https?:\/\//.test(url)) { | ||||
|                 return context.createError({ message: 'http or https required' }) | ||||
|               } | ||||
|               return true | ||||
|             } | ||||
|             try { | ||||
|               // force HTTPS over clearnet
 | ||||
|               await string().https().validate(url) | ||||
|             } catch (err) { | ||||
|               return context.createError({ message: err.message }) | ||||
|             } | ||||
|             return true | ||||
|           }) | ||||
|     } | ||||
| 
 | ||||
|     if (words) { | ||||
|       validator = array() | ||||
|         .transform(function (value, originalValue) { | ||||
|           if (this.isType(value) && value !== null) { | ||||
|             return value | ||||
|           } | ||||
|           return originalValue ? originalValue.trim().split(/[\s]+/) : [] | ||||
|         }) | ||||
|         .test(async (values, context) => { | ||||
|           for (const v of values) { | ||||
|             try { | ||||
|               await string().oneOf(words).validate(v) | ||||
|             } catch { | ||||
|               return context.createError({ message: `'${v}' is not a valid ${field.label} word` }) | ||||
|             } | ||||
|           } | ||||
|           return true | ||||
|         }) | ||||
|     } | ||||
| 
 | ||||
|     if (validationType === 'email') validator = lightningAddressValidator | ||||
| 
 | ||||
|     if (validationType === 'socket') validator = string().socket() | ||||
| 
 | ||||
|     if (validationType === 'hexOrBase64') validator = hexOrBase64Validator | ||||
| 
 | ||||
|     if (validationType === 'b64url') validator = string().matches(B64_URL_REGEX, { message: `invalid ${field.name}` }) | ||||
| 
 | ||||
|     if (min !== undefined) validator = validator.min(min) | ||||
|     if (max !== undefined) validator = validator.max(max) | ||||
| 
 | ||||
|     if (field.validate.length) validator = validator.length(field.validate.length) | ||||
| 
 | ||||
|     if (!field.optional) validator = validator.required('required') | ||||
| 
 | ||||
|     if (field.validate.test) { | ||||
|       validator = validator.test({ | ||||
|         name: field.name, | ||||
|         ...(typeof field.validate.test === 'function' ? { test: field.validate.test } : field.validate.test) | ||||
|       }) | ||||
|     } | ||||
| 
 | ||||
|     return validator | ||||
|   } | ||||
| 
 | ||||
|   return object({ | ||||
|     ...wallet.fields.reduce((acc, field) => { | ||||
|       return { | ||||
|         ...acc, | ||||
|         [field.name]: fieldValidator(field) | ||||
|       } | ||||
|     }, {}), | ||||
|     ...(wallet.walletType ? autowithdrawSchemaMembers : {}) | ||||
|   }) | ||||
| } | ||||
| @ -232,7 +232,10 @@ module.exports = withPlausibleProxy()({ | ||||
|         }) | ||||
|       } | ||||
| 
 | ||||
|       // const ignorePlugin = new webpack.IgnorePlugin({ resourceRegExp: /server\.js$/ })
 | ||||
| 
 | ||||
|       config.plugins.push(workboxPlugin) | ||||
|       // config.plugins.push(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> | ||||
|                   <ServiceWorkerProvider> | ||||
|                     <PriceProvider price={price}> | ||||
|                       <LightningProvider> | ||||
|                         <ToastProvider> | ||||
|                           <WebLNProvider> | ||||
|                   <WalletLoggerProvider> | ||||
|                     <ServiceWorkerProvider> | ||||
|                       <PriceProvider price={price}> | ||||
|                         <LightningProvider> | ||||
|                           <ToastProvider> | ||||
|                             <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> | ||||
|                           </ToastProvider> | ||||
|                         </LightningProvider> | ||||
|                       </PriceProvider> | ||||
|                     </ServiceWorkerProvider> | ||||
|                   </WalletLoggerProvider> | ||||
|                 </LoggerProvider> | ||||
|               </HasNewNotesProvider> | ||||
|             </MeProvider> | ||||
|  | ||||
| @ -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> | ||||
|   ) | ||||
| } | ||||
|  | ||||
							
								
								
									
										140
									
								
								pages/settings/wallets/[wallet].js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										140
									
								
								pages/settings/wallets/[wallet].js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,140 @@ | ||||
| import { getGetServerSideProps } from '@/api/ssrApollo' | ||||
| import { Form, ClientInput, ClientCheckbox, PasswordInput } from '@/components/form' | ||||
| import { CenterLayout } from '@/components/layout' | ||||
| import { WalletSecurityBanner } from '@/components/banners' | ||||
| import { WalletLogs } from '@/components/wallet-logger' | ||||
| import { useToast } from '@/components/toast' | ||||
| import { useRouter } from 'next/router' | ||||
| import { useWallet, Status } from 'wallets' | ||||
| import Info from '@/components/info' | ||||
| import Text from '@/components/text' | ||||
| import { AutowithdrawSettings } from '@/components/autowithdraw-shared' | ||||
| import dynamic from 'next/dynamic' | ||||
| import { generateSchema } from '@/lib/wallet' | ||||
| 
 | ||||
| const WalletButtonBar = dynamic(() => import('@/components/wallet-buttonbar.js'), { ssr: false }) | ||||
| 
 | ||||
| export const getServerSideProps = getGetServerSideProps({ authRequired: true }) | ||||
| 
 | ||||
| export default function WalletSettings () { | ||||
|   const toaster = useToast() | ||||
|   const router = useRouter() | ||||
|   const { wallet: name } = router.query | ||||
|   const wallet = useWallet(name) | ||||
| 
 | ||||
|   const initial = wallet.fields.reduce((acc, field) => { | ||||
|     // We still need to run over all wallet fields via reduce
 | ||||
|     // even though we use wallet.config as the initial value
 | ||||
|     // since wallet.config is empty when wallet is not configured.
 | ||||
|     // Also, wallet.config includes general fields like
 | ||||
|     // 'enabled' and 'priority' which are not defined in wallet.fields.
 | ||||
|     return { | ||||
|       ...acc, | ||||
|       [field.name]: wallet.config?.[field.name] || '' | ||||
|     } | ||||
|   }, wallet.config) | ||||
| 
 | ||||
|   const schema = generateSchema(wallet) | ||||
| 
 | ||||
|   return ( | ||||
|     <CenterLayout> | ||||
|       <h2 className='pb-2'>{wallet.card.title}</h2> | ||||
|       <h6 className='text-muted text-center pb-3'><Text>{wallet.card.subtitle}</Text></h6> | ||||
|       {!wallet.walletType && <WalletSecurityBanner />} | ||||
|       <Form | ||||
|         initial={initial} | ||||
|         schema={schema} | ||||
|         onSubmit={async ({ amount, ...values }) => { | ||||
|           try { | ||||
|             const newConfig = !wallet.isConfigured | ||||
| 
 | ||||
|             // enable wallet if wallet was just configured
 | ||||
|             if (newConfig) { | ||||
|               values.enabled = true | ||||
|             } | ||||
| 
 | ||||
|             await wallet.save(values) | ||||
| 
 | ||||
|             if (values.enabled) wallet.enable() | ||||
|             else wallet.disable() | ||||
| 
 | ||||
|             toaster.success('saved settings') | ||||
|             router.push('/settings/wallets') | ||||
|           } catch (err) { | ||||
|             console.error(err) | ||||
|             const message = 'failed to attach: ' + err.message || err.toString?.() | ||||
|             toaster.danger(message) | ||||
|           } | ||||
|         }} | ||||
|       > | ||||
|         <WalletFields wallet={wallet} /> | ||||
|         {wallet.walletType | ||||
|           ? <AutowithdrawSettings wallet={wallet} /> | ||||
|           : ( | ||||
|             <ClientCheckbox | ||||
|               disabled={!wallet.isConfigured} | ||||
|               initialValue={wallet.status === Status.Enabled} | ||||
|               label='enabled' | ||||
|               name='enabled' | ||||
|             /> | ||||
|             )} | ||||
|         <WalletButtonBar | ||||
|           wallet={wallet} onDelete={async () => { | ||||
|             try { | ||||
|               await wallet.delete() | ||||
|               toaster.success('saved settings') | ||||
|               router.push('/settings/wallets') | ||||
|             } catch (err) { | ||||
|               console.error(err) | ||||
|               const message = 'failed to detach: ' + err.message || err.toString?.() | ||||
|               toaster.danger(message) | ||||
|             } | ||||
|           }} | ||||
|         /> | ||||
|       </Form> | ||||
|       <div className='mt-3 w-100'> | ||||
|         <WalletLogs wallet={wallet} embedded /> | ||||
|       </div> | ||||
|     </CenterLayout> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| function WalletFields ({ wallet: { config, fields, isConfigured } }) { | ||||
|   return fields | ||||
|     .map(({ name, label, type, help, optional, editable, ...props }, i) => { | ||||
|       const rawProps = { | ||||
|         ...props, | ||||
|         name, | ||||
|         initialValue: config?.[name], | ||||
|         readOnly: isConfigured && editable === false, | ||||
|         groupClassName: props.hidden ? 'd-none' : undefined, | ||||
|         label: label | ||||
|           ? ( | ||||
|             <div className='d-flex align-items-center'> | ||||
|               {label} | ||||
|               {/* help can be a string or object to customize the label */} | ||||
|               {help && ( | ||||
|                 <Info label={help.label || 'help'}> | ||||
|                   <Text>{help.text || help}</Text> | ||||
|                 </Info> | ||||
|               )} | ||||
|               {optional && ( | ||||
|                 <small className='text-muted ms-2'> | ||||
|                   {typeof optional === 'boolean' ? 'optional' : <Text>{optional}</Text>} | ||||
|                 </small> | ||||
|               )} | ||||
|             </div> | ||||
|             ) | ||||
|           : undefined, | ||||
|         required: !optional, | ||||
|         autoFocus: i === 0 | ||||
|       } | ||||
|       if (type === 'text') { | ||||
|         return <ClientInput key={i} {...rawProps} /> | ||||
|       } | ||||
|       if (type === 'password') { | ||||
|         return <PasswordInput key={i} {...rawProps} newPass /> | ||||
|       } | ||||
|       return null | ||||
|     }) | ||||
| } | ||||
| @ -1,137 +0,0 @@ | ||||
| import { getGetServerSideProps } from '@/api/ssrApollo' | ||||
| import { Form, Input } from '@/components/form' | ||||
| import { CenterLayout } from '@/components/layout' | ||||
| import { useMe } from '@/components/me' | ||||
| import { WalletButtonBar, WalletCard } from '@/components/wallet-card' | ||||
| import { useApolloClient, useMutation } from '@apollo/client' | ||||
| import { useToast } from '@/components/toast' | ||||
| import { CLNAutowithdrawSchema } from '@/lib/validate' | ||||
| import { useRouter } from 'next/router' | ||||
| import { AutowithdrawSettings, autowithdrawInitial } from '@/components/autowithdraw-shared' | ||||
| import { REMOVE_WALLET, UPSERT_WALLET_CLN, WALLET_BY_TYPE } from '@/fragments/wallet' | ||||
| import WalletLogs from '@/components/wallet-logs' | ||||
| import Info from '@/components/info' | ||||
| import Text from '@/components/text' | ||||
| import { Wallet } from '@/lib/constants' | ||||
| 
 | ||||
| const variables = { type: Wallet.CLN.type } | ||||
| export const getServerSideProps = getGetServerSideProps({ query: WALLET_BY_TYPE, variables, authRequired: true }) | ||||
| 
 | ||||
| export default function CLN ({ ssrData }) { | ||||
|   const me = useMe() | ||||
|   const toaster = useToast() | ||||
|   const router = useRouter() | ||||
|   const client = useApolloClient() | ||||
|   const [upsertWalletCLN] = useMutation(UPSERT_WALLET_CLN, { | ||||
|     refetchQueries: ['WalletLogs'], | ||||
|     onError: (err) => { | ||||
|       client.refetchQueries({ include: ['WalletLogs'] }) | ||||
|       throw err | ||||
|     } | ||||
|   }) | ||||
|   const [removeWallet] = useMutation(REMOVE_WALLET, { | ||||
|     refetchQueries: ['WalletLogs'], | ||||
|     onError: (err) => { | ||||
|       client.refetchQueries({ include: ['WalletLogs'] }) | ||||
|       throw err | ||||
|     } | ||||
|   }) | ||||
| 
 | ||||
|   const { walletByType: wallet } = ssrData || {} | ||||
| 
 | ||||
|   return ( | ||||
|     <CenterLayout> | ||||
|       <h2 className='pb-2'>CLN</h2> | ||||
|       <h6 className='text-muted text-center'>autowithdraw to your Core Lightning node via <a href='https://docs.corelightning.org/docs/rest' target='_blank' noreferrer rel='noreferrer'>CLNRest</a></h6> | ||||
|       <Form | ||||
|         initial={{ | ||||
|           socket: wallet?.wallet?.socket || '', | ||||
|           rune: wallet?.wallet?.rune || '', | ||||
|           cert: wallet?.wallet?.cert || '', | ||||
|           ...autowithdrawInitial({ me, priority: wallet?.priority }) | ||||
|         }} | ||||
|         schema={CLNAutowithdrawSchema({ me })} | ||||
|         onSubmit={async ({ socket, rune, cert, ...settings }) => { | ||||
|           try { | ||||
|             await upsertWalletCLN({ | ||||
|               variables: { | ||||
|                 id: wallet?.id, | ||||
|                 socket, | ||||
|                 rune, | ||||
|                 cert, | ||||
|                 settings: { | ||||
|                   ...settings, | ||||
|                   autoWithdrawThreshold: Number(settings.autoWithdrawThreshold), | ||||
|                   autoWithdrawMaxFeePercent: Number(settings.autoWithdrawMaxFeePercent) | ||||
|                 } | ||||
|               } | ||||
|             }) | ||||
|             toaster.success('saved settings') | ||||
|             router.push('/settings/wallets') | ||||
|           } catch (err) { | ||||
|             console.error(err) | ||||
|           } | ||||
|         }} | ||||
|       > | ||||
|         <Input | ||||
|           label='rest host and port' | ||||
|           name='socket' | ||||
|           hint='tor or clearnet' | ||||
|           placeholder='55.5.555.55:3010' | ||||
|           clear | ||||
|           required | ||||
|           autoFocus | ||||
|         /> | ||||
|         <Input | ||||
|           label={ | ||||
|             <div className='d-flex align-items-center'>invoice only rune | ||||
|               <Info> | ||||
|                 <Text> | ||||
|                   {'We only accept runes that *only* allow `method=invoice`.\nRun this to generate one:\n\n```lightning-cli createrune restrictions=\'["method=invoice"]\'```'} | ||||
|                 </Text> | ||||
|               </Info> | ||||
|             </div> | ||||
|           } | ||||
|           name='rune' | ||||
|           clear | ||||
|           hint='must be restricted to method=invoice' | ||||
|           placeholder='S34KtUW-6gqS_hD_9cc_PNhfF-NinZyBOCgr1aIrark9NCZtZXRob2Q9aW52b2ljZQ==' | ||||
|           required | ||||
|         /> | ||||
|         <Input | ||||
|           label={<>cert <small className='text-muted ms-2'>optional if from <a href='https://en.wikipedia.org/wiki/Certificate_authority' target='_blank' rel='noreferrer'>CA</a> (e.g. voltage)</small></>} | ||||
|           name='cert' | ||||
|           clear | ||||
|           hint='hex or base64 encoded' | ||||
|           placeholder='LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNNVENDQWRpZ0F3SUJBZ0lRSHBFdFdrcGJwZHV4RVF2eVBPc3NWVEFLQmdncWhrak9QUVFEQWpBdk1SOHcKSFFZRFZRUUtFeFpzYm1RZ1lYVjBiMmRsYm1WeVlYUmxaQ0JqWlhKME1Rd3dDZ1lEVlFRREV3TmliMkl3SGhjTgpNalF3TVRBM01qQXhORE0wV2hjTk1qVXdNekF6TWpBeE5ETTBXakF2TVI4d0hRWURWUVFLRXhac2JtUWdZWFYwCmIyZGxibVZ5WVhSbFpDQmpaWEowTVF3d0NnWURWUVFERXdOaWIySXdXVEFUQmdjcWhrak9QUUlCQmdncWhrak8KUFFNQkJ3TkNBQVJUS3NMVk5oZnhqb1FLVDlkVVdDbzUzSmQwTnBuL1BtYi9LUE02M1JxbU52dFYvdFk4NjJJZwpSbE41cmNHRnBEajhUeFc2OVhIK0pTcHpjWDdlN3N0Um80SFZNSUhTTUE0R0ExVWREd0VCL3dRRUF3SUNwREFUCkJnTlZIU1VFRERBS0JnZ3JCZ0VGQlFjREFUQVBCZ05WSFJNQkFmOEVCVEFEQVFIL01CMEdBMVVkRGdRV0JCVDAKMnh3V25GeHRUNzI0MWxwZlNoNm9FWi9UMWpCN0JnTlZIUkVFZERCeWdnTmliMktDQ1d4dlkyRnNhRzl6ZElJRApZbTlpZ2d4d2IyeGhjaTF1TVMxaWIyS0NGR2h2YzNRdVpHOWphMlZ5TG1sdWRHVnlibUZzZ2dSMWJtbDRnZ3AxCmJtbDRjR0ZqYTJWMGdnZGlkV1pqYjI1dWh3Ui9BQUFCaHhBQUFBQUFBQUFBQUFBQUFBQUFBQUFCaHdTc0VnQUQKTUFvR0NDcUdTTTQ5QkFNQ0EwY0FNRVFDSUEwUTlkRXdoNXpPRnpwL3hYeHNpemh5SkxNVG5yazU1VWx1NHJPRwo4WW52QWlBVGt4U3p3Y3hZZnFscGx0UlNIbmd0NUJFcDBzcXlHL05nenBzb2pmMGNqQT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K' | ||||
|         /> | ||||
|         <AutowithdrawSettings /> | ||||
|         <WalletButtonBar | ||||
|           status={!!wallet} onDelete={async () => { | ||||
|             try { | ||||
|               await removeWallet({ variables: { id: wallet?.id } }) | ||||
|               toaster.success('saved settings') | ||||
|               router.push('/settings/wallets') | ||||
|             } catch (err) { | ||||
|               console.error(err) | ||||
|             } | ||||
|           }} | ||||
|         /> | ||||
|       </Form> | ||||
|       <div className='mt-3 w-100'> | ||||
|         <WalletLogs wallet={Wallet.CLN} embedded /> | ||||
|       </div> | ||||
|     </CenterLayout> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| export function CLNCard ({ wallet }) { | ||||
|   return ( | ||||
|     <WalletCard | ||||
|       title='CLN' | ||||
|       badges={['receive only', 'non-custodial']} | ||||
|       provider='cln' | ||||
|       status={wallet !== undefined || undefined} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
| @ -1,29 +1,75 @@ | ||||
| import { getGetServerSideProps } from '@/api/ssrApollo' | ||||
| import 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; | ||||
|  | ||||
							
								
								
									
										8
									
								
								wallets/client.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								wallets/client.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,8 @@ | ||||
| import * as nwc from 'wallets/nwc/client' | ||||
| import * as lnbits from 'wallets/lnbits/client' | ||||
| import * as lnc from 'wallets/lnc/client' | ||||
| import * as lnAddr from 'wallets/lightning-address/client' | ||||
| import * as cln from 'wallets/cln/client' | ||||
| import * as lnd from 'wallets/lnd/client' | ||||
| 
 | ||||
| export default [nwc, lnbits, lnc, lnAddr, cln, lnd] | ||||
							
								
								
									
										1
									
								
								wallets/cln/client.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								wallets/cln/client.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1 @@ | ||||
| export * from 'wallets/cln' | ||||
							
								
								
									
										67
									
								
								wallets/cln/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								wallets/cln/index.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,67 @@ | ||||
| import { decodeRune } from '@/lib/cln' | ||||
| 
 | ||||
| export const name = 'cln' | ||||
| 
 | ||||
| export const fields = [ | ||||
|   { | ||||
|     name: 'socket', | ||||
|     label: 'rest host and port', | ||||
|     type: 'text', | ||||
|     placeholder: '55.5.555.55:3010', | ||||
|     hint: 'tor or clearnet', | ||||
|     clear: true, | ||||
|     validate: { | ||||
|       type: 'socket' | ||||
|     } | ||||
|   }, | ||||
|   { | ||||
|     name: 'rune', | ||||
|     label: 'invoice only rune', | ||||
|     help: { | ||||
|       text: 'We only accept runes that *only* allow `method=invoice`.\nRun this to generate one:\n\n```lightning-cli createrune restrictions=\'["method=invoice"]\'```' | ||||
|     }, | ||||
|     type: 'text', | ||||
|     placeholder: 'S34KtUW-6gqS_hD_9cc_PNhfF-NinZyBOCgr1aIrark9NCZtZXRob2Q9aW52b2ljZQ==', | ||||
|     hint: 'must be restricted to method=invoice', | ||||
|     clear: true, | ||||
|     validate: { | ||||
|       type: 'b64url', | ||||
|       test: (v, context) => { | ||||
|         const decoded = decodeRune(v) | ||||
|         if (!decoded) return context.createError({ message: 'invalid rune' }) | ||||
|         if (decoded.restrictions.length === 0) { | ||||
|           return context.createError({ message: 'rune must be restricted to method=invoice' }) | ||||
|         } | ||||
|         if (decoded.restrictions.length !== 1 || decoded.restrictions[0].alternatives.length !== 1) { | ||||
|           return context.createError({ message: 'rune must be restricted to method=invoice only' }) | ||||
|         } | ||||
|         if (decoded.restrictions[0].alternatives[0] !== 'method=invoice') { | ||||
|           return context.createError({ message: 'rune must be restricted to method=invoice only' }) | ||||
|         } | ||||
|         return true | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   { | ||||
|     name: 'cert', | ||||
|     label: 'cert', | ||||
|     type: 'text', | ||||
|     placeholder: 'LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNNVENDQWRpZ0F3SUJBZ0lRSHBFdFdrcGJwZHV4RVF2eVBPc3NWVEFLQmdncWhrak9QUVFEQWpBdk1SOHcKSFFZRFZRUUtFeFpzYm1RZ1lYVjBiMmRsYm1WeVlYUmxaQ0JqWlhKME1Rd3dDZ1lEVlFRREV3TmliMkl3SGhjTgpNalF3TVRBM01qQXhORE0wV2hjTk1qVXdNekF6TWpBeE5ETTBXakF2TVI4d0hRWURWUVFLRXhac2JtUWdZWFYwCmIyZGxibVZ5WVhSbFpDQmpaWEowTVF3d0NnWURWUVFERXdOaWIySXdXVEFUQmdjcWhrak9QUUlCQmdncWhrak8KUFFNQkJ3TkNBQVJUS3NMVk5oZnhqb1FLVDlkVVdDbzUzSmQwTnBuL1BtYi9LUE02M1JxbU52dFYvdFk4NjJJZwpSbE41cmNHRnBEajhUeFc2OVhIK0pTcHpjWDdlN3N0Um80SFZNSUhTTUE0R0ExVWREd0VCL3dRRUF3SUNwREFUCkJnTlZIU1VFRERBS0JnZ3JCZ0VGQlFjREFUQVBCZ05WSFJNQkFmOEVCVEFEQVFIL01CMEdBMVVkRGdRV0JCVDAKMnh3V25GeHRUNzI0MWxwZlNoNm9FWi9UMWpCN0JnTlZIUkVFZERCeWdnTmliMktDQ1d4dlkyRnNhRzl6ZElJRApZbTlpZ2d4d2IyeGhjaTF1TVMxaWIyS0NGR2h2YzNRdVpHOWphMlZ5TG1sdWRHVnlibUZzZ2dSMWJtbDRnZ3AxCmJtbDRjR0ZqYTJWMGdnZGlkV1pqYjI1dWh3Ui9BQUFCaHhBQUFBQUFBQUFBQUFBQUFBQUFBQUFCaHdTc0VnQUQKTUFvR0NDcUdTTTQ5QkFNQ0EwY0FNRVFDSUEwUTlkRXdoNXpPRnpwL3hYeHNpemh5SkxNVG5yazU1VWx1NHJPRwo4WW52QWlBVGt4U3p3Y3hZZnFscGx0UlNIbmd0NUJFcDBzcXlHL05nenBzb2pmMGNqQT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K', | ||||
|     optional: 'optional if from [CA](https://en.wikipedia.org/wiki/Certificate_authority) (e.g. voltage)', | ||||
|     hint: 'hex or base64 encoded', | ||||
|     clear: true, | ||||
|     validate: { | ||||
|       type: 'hexOrBase64' | ||||
|     } | ||||
|   } | ||||
| ] | ||||
| 
 | ||||
| export const card = { | ||||
|   title: 'CLN', | ||||
|   subtitle: 'autowithdraw to your Core Lightning node via [CLNRest](https://docs.corelightning.org/docs/rest)', | ||||
|   badges: ['receive only', 'non-custodialish'] | ||||
| } | ||||
| 
 | ||||
| export const walletType = 'CLN' | ||||
| 
 | ||||
| export const walletField = 'walletCLN' | ||||
							
								
								
									
										40
									
								
								wallets/cln/server.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								wallets/cln/server.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,40 @@ | ||||
| import { ensureB64 } from '@/lib/format' | ||||
| import { createInvoice as clnCreateInvoice } from '@/lib/cln' | ||||
| import { addWalletLog } from '@/api/resolvers/wallet' | ||||
| 
 | ||||
| export * from 'wallets/cln' | ||||
| 
 | ||||
| export const testConnect = async ( | ||||
|   { socket, rune, cert }, | ||||
|   { me, models } | ||||
| ) => { | ||||
|   cert = ensureB64(cert) | ||||
|   const inv = await clnCreateInvoice({ | ||||
|     socket, | ||||
|     rune, | ||||
|     cert, | ||||
|     description: 'SN connection test', | ||||
|     msats: 'any', | ||||
|     expiry: 0 | ||||
|   }) | ||||
|   await addWalletLog({ wallet: { type: 'CLN' }, level: 'SUCCESS', message: 'connected to CLN' }, { me, models }) | ||||
|   return inv | ||||
| } | ||||
| 
 | ||||
| export const createInvoice = async ( | ||||
|   { amount }, | ||||
|   { socket, rune, cert }, | ||||
|   { me, models, lnd } | ||||
| ) => { | ||||
|   cert = ensureB64(cert) | ||||
| 
 | ||||
|   const inv = await clnCreateInvoice({ | ||||
|     socket, | ||||
|     rune, | ||||
|     cert, | ||||
|     description: me.hideInvoiceDesc ? undefined : 'autowithdraw to CLN from SN', | ||||
|     msats: amount + 'sat', | ||||
|     expiry: 360 | ||||
|   }) | ||||
|   return inv.bolt11 | ||||
| } | ||||
							
								
								
									
										321
									
								
								wallets/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										321
									
								
								wallets/index.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,321 @@ | ||||
| import { useCallback } from 'react' | ||||
| import { useMe } from '@/components/me' | ||||
| import useLocalConfig from '@/components/use-local-state' | ||||
| import { useWalletLogger } from '@/components/wallet-logger' | ||||
| import { SSR } from '@/lib/constants' | ||||
| import { bolt11Tags } from '@/lib/bolt11' | ||||
| 
 | ||||
| import walletDefs from 'wallets/client' | ||||
| import { gql, useApolloClient, useQuery } from '@apollo/client' | ||||
| import { REMOVE_WALLET, WALLET_BY_TYPE } from '@/fragments/wallet' | ||||
| import { autowithdrawInitial } from '@/components/autowithdraw-shared' | ||||
| import { useShowModal } from '@/components/modal' | ||||
| import { useToast } from '../components/toast' | ||||
| import { generateResolverName } from '@/lib/wallet' | ||||
| 
 | ||||
| export const Status = { | ||||
|   Initialized: 'Initialized', | ||||
|   Enabled: 'Enabled', | ||||
|   Locked: 'Locked', | ||||
|   Error: 'Error' | ||||
| } | ||||
| 
 | ||||
| export function useWallet (name) { | ||||
|   const me = useMe() | ||||
|   const showModal = useShowModal() | ||||
|   const toaster = useToast() | ||||
| 
 | ||||
|   const wallet = name ? getWalletByName(name) : getEnabledWallet(me) | ||||
|   const { logger, deleteLogs } = useWalletLogger(wallet) | ||||
| 
 | ||||
|   const [config, saveConfig, clearConfig] = useConfig(wallet) | ||||
|   const _isConfigured = isConfigured({ ...wallet, config }) | ||||
| 
 | ||||
|   const status = config?.enabled ? Status.Enabled : Status.Initialized | ||||
|   const enabled = status === Status.Enabled | ||||
|   const priority = config?.priority | ||||
| 
 | ||||
|   const sendPayment = useCallback(async (bolt11) => { | ||||
|     const hash = bolt11Tags(bolt11).payment_hash | ||||
|     logger.info('sending payment:', `payment_hash=${hash}`) | ||||
|     try { | ||||
|       const { preimage } = await wallet.sendPayment(bolt11, config, { me, logger, status, showModal }) | ||||
|       logger.ok('payment successful:', `payment_hash=${hash}`, `preimage=${preimage}`) | ||||
|     } catch (err) { | ||||
|       const message = err.message || err.toString?.() | ||||
|       logger.error('payment failed:', `payment_hash=${hash}`, message) | ||||
|       throw err | ||||
|     } | ||||
|   }, [me, wallet, config, logger, status]) | ||||
| 
 | ||||
|   const enable = useCallback(() => { | ||||
|     enableWallet(name, me) | ||||
|     logger.ok('wallet enabled') | ||||
|   }, [name, me, logger]) | ||||
| 
 | ||||
|   const disable = useCallback(() => { | ||||
|     disableWallet(name, me) | ||||
|     logger.info('wallet disabled') | ||||
|   }, [name, me, logger]) | ||||
| 
 | ||||
|   const setPriority = useCallback(async (priority) => { | ||||
|     if (_isConfigured && priority !== config.priority) { | ||||
|       try { | ||||
|         await saveConfig({ ...config, priority }) | ||||
|       } catch (err) { | ||||
|         toaster.danger(`failed to change priority of ${wallet.name} wallet: ${err.message}`) | ||||
|       } | ||||
|     } | ||||
|   }, [wallet, config, logger, toaster]) | ||||
| 
 | ||||
|   const save = useCallback(async (newConfig) => { | ||||
|     try { | ||||
|       // validate should log custom INFO and OK message
 | ||||
|       // validate is optional since validation might happen during save on server
 | ||||
|       // TODO: add timeout
 | ||||
|       const validConfig = await wallet.validate?.(newConfig, { me, logger }) | ||||
|       await saveConfig(validConfig ?? newConfig) | ||||
|       logger.ok(_isConfigured ? 'wallet updated' : 'wallet attached') | ||||
|     } catch (err) { | ||||
|       const message = err.message || err.toString?.() | ||||
|       logger.error('failed to attach: ' + message) | ||||
|       throw err | ||||
|     } | ||||
|   }, [_isConfigured, saveConfig, me, logger]) | ||||
| 
 | ||||
|   // delete is a reserved keyword
 | ||||
|   const delete_ = useCallback(async () => { | ||||
|     try { | ||||
|       await clearConfig() | ||||
|       logger.ok('wallet detached') | ||||
|       disable() | ||||
|     } catch (err) { | ||||
|       const message = err.message || err.toString?.() | ||||
|       logger.error(message) | ||||
|       throw err | ||||
|     } | ||||
|   }, [clearConfig, logger, disable]) | ||||
| 
 | ||||
|   if (!wallet) return null | ||||
| 
 | ||||
|   return { | ||||
|     ...wallet, | ||||
|     sendPayment, | ||||
|     config, | ||||
|     save, | ||||
|     delete: delete_, | ||||
|     deleteLogs, | ||||
|     enable, | ||||
|     disable, | ||||
|     setPriority, | ||||
|     isConfigured: _isConfigured, | ||||
|     status, | ||||
|     enabled, | ||||
|     priority, | ||||
|     logger | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| function useConfig (wallet) { | ||||
|   const me = useMe() | ||||
| 
 | ||||
|   const storageKey = getStorageKey(wallet?.name, me) | ||||
|   const [localConfig, setLocalConfig, clearLocalConfig] = useLocalConfig(storageKey) | ||||
| 
 | ||||
|   const [serverConfig, setServerConfig, clearServerConfig] = useServerConfig(wallet) | ||||
| 
 | ||||
|   const hasLocalConfig = !!wallet?.sendPayment | ||||
|   const hasServerConfig = !!wallet?.walletType | ||||
| 
 | ||||
|   const config = { | ||||
|     // only include config if it makes sense for this wallet
 | ||||
|     // since server config always returns default values for autowithdraw settings
 | ||||
|     // which might be confusing to have for wallets that don't support autowithdraw
 | ||||
|     ...(hasLocalConfig ? localConfig : {}), | ||||
|     ...(hasServerConfig ? serverConfig : {}) | ||||
|   } | ||||
| 
 | ||||
|   const saveConfig = useCallback(async (config) => { | ||||
|     if (hasLocalConfig) setLocalConfig(config) | ||||
|     if (hasServerConfig) await setServerConfig(config) | ||||
|   }, [wallet]) | ||||
| 
 | ||||
|   const clearConfig = useCallback(async () => { | ||||
|     if (hasLocalConfig) clearLocalConfig() | ||||
|     if (hasServerConfig) await clearServerConfig() | ||||
|   }, [wallet]) | ||||
| 
 | ||||
|   return [config, saveConfig, clearConfig] | ||||
| } | ||||
| 
 | ||||
| function isConfigured ({ fields, config }) { | ||||
|   if (!config || !fields) return false | ||||
| 
 | ||||
|   // a wallet is configured if all of its required fields are set
 | ||||
|   const val = fields.every(field => { | ||||
|     return field.optional ? true : !!config?.[field.name] | ||||
|   }) | ||||
| 
 | ||||
|   return val | ||||
| } | ||||
| 
 | ||||
| function useServerConfig (wallet) { | ||||
|   const client = useApolloClient() | ||||
|   const me = useMe() | ||||
| 
 | ||||
|   const { data, refetch: refetchConfig } = useQuery(WALLET_BY_TYPE, { variables: { type: wallet?.walletType }, skip: !wallet?.walletType }) | ||||
| 
 | ||||
|   const walletId = data?.walletByType?.id | ||||
|   const serverConfig = { | ||||
|     id: walletId, | ||||
|     priority: data?.walletByType?.priority, | ||||
|     enabled: data?.walletByType?.enabled, | ||||
|     ...data?.walletByType?.wallet | ||||
|   } | ||||
|   const autowithdrawSettings = autowithdrawInitial({ me }) | ||||
|   const config = { ...serverConfig, ...autowithdrawSettings } | ||||
| 
 | ||||
|   const saveConfig = useCallback(async ({ | ||||
|     autoWithdrawThreshold, | ||||
|     autoWithdrawMaxFeePercent, | ||||
|     priority, | ||||
|     enabled, | ||||
|     ...config | ||||
|   }) => { | ||||
|     try { | ||||
|       const mutation = generateMutation(wallet) | ||||
|       return await client.mutate({ | ||||
|         mutation, | ||||
|         variables: { | ||||
|           id: walletId, | ||||
|           ...config, | ||||
|           settings: { | ||||
|             autoWithdrawThreshold: Number(autoWithdrawThreshold), | ||||
|             autoWithdrawMaxFeePercent: Number(autoWithdrawMaxFeePercent), | ||||
|             priority, | ||||
|             enabled | ||||
|           } | ||||
|         } | ||||
|       }) | ||||
|     } finally { | ||||
|       client.refetchQueries({ include: ['WalletLogs'] }) | ||||
|       refetchConfig() | ||||
|     } | ||||
|   }, [client, walletId]) | ||||
| 
 | ||||
|   const clearConfig = useCallback(async () => { | ||||
|     try { | ||||
|       await client.mutate({ | ||||
|         mutation: REMOVE_WALLET, | ||||
|         variables: { id: walletId } | ||||
|       }) | ||||
|     } finally { | ||||
|       client.refetchQueries({ include: ['WalletLogs'] }) | ||||
|       refetchConfig() | ||||
|     } | ||||
|   }, [client, walletId]) | ||||
| 
 | ||||
|   return [config, saveConfig, clearConfig] | ||||
| } | ||||
| 
 | ||||
| function generateMutation (wallet) { | ||||
|   const resolverName = generateResolverName(wallet.walletField) | ||||
| 
 | ||||
|   let headerArgs = '$id: ID, ' | ||||
|   headerArgs += wallet.fields.map(f => { | ||||
|     let arg = `$${f.name}: String` | ||||
|     if (!f.optional) { | ||||
|       arg += '!' | ||||
|     } | ||||
|     return arg | ||||
|   }).join(', ') | ||||
|   headerArgs += ', $settings: AutowithdrawSettings!' | ||||
| 
 | ||||
|   let inputArgs = 'id: $id, ' | ||||
|   inputArgs += wallet.fields.map(f => `${f.name}: $${f.name}`).join(', ') | ||||
|   inputArgs += ', settings: $settings' | ||||
| 
 | ||||
|   return gql`mutation ${resolverName}(${headerArgs}) {
 | ||||
|     ${resolverName}(${inputArgs}) | ||||
|   }` | ||||
| } | ||||
| 
 | ||||
| export function getWalletByName (name) { | ||||
|   return walletDefs.find(def => def.name === name) | ||||
| } | ||||
| 
 | ||||
| export function getWalletByType (type) { | ||||
|   return walletDefs.find(def => def.walletType === type) | ||||
| } | ||||
| 
 | ||||
| export function getEnabledWallet (me) { | ||||
|   return walletDefs | ||||
|     .filter(def => !!def.sendPayment) | ||||
|     .map(def => { | ||||
|       // populate definition with properties from useWallet that are required for sorting
 | ||||
|       const key = getStorageKey(def.name, me) | ||||
|       const config = SSR ? null : JSON.parse(window?.localStorage.getItem(key)) | ||||
|       const priority = config?.priority | ||||
|       return { ...def, config, priority } | ||||
|     }) | ||||
|     .filter(({ config }) => config?.enabled) | ||||
|     .sort(walletPrioritySort)[0] | ||||
| } | ||||
| 
 | ||||
| export function walletPrioritySort (w1, w2) { | ||||
|   const delta = w1.priority - w2.priority | ||||
|   // delta is NaN if either priority is undefined
 | ||||
|   if (!Number.isNaN(delta) && delta !== 0) return delta | ||||
| 
 | ||||
|   // if one wallet has a priority but the other one doesn't, the one with the priority comes first
 | ||||
|   if (w1.priority !== undefined && w2.priority === undefined) return -1 | ||||
|   if (w1.priority === undefined && w2.priority !== undefined) return 1 | ||||
| 
 | ||||
|   // both wallets have no priority set, falling back to other methods
 | ||||
| 
 | ||||
|   // if both wallets have an id, use that as tie breaker
 | ||||
|   // since that's the order in which autowithdrawals are attempted
 | ||||
|   if (w1.config?.id && w2.config?.id) return Number(w1.config.id) - Number(w2.config.id) | ||||
| 
 | ||||
|   // else we will use the card title as tie breaker
 | ||||
|   return w1.card.title < w2.card.title ? -1 : 1 | ||||
| } | ||||
| 
 | ||||
| export function useWallets () { | ||||
|   const wallets = walletDefs.map(def => useWallet(def.name)) | ||||
| 
 | ||||
|   const resetClient = useCallback(async (wallet) => { | ||||
|     for (const w of wallets) { | ||||
|       if (w.sendPayment) { | ||||
|         await w.delete() | ||||
|       } | ||||
|       await w.deleteLogs() | ||||
|     } | ||||
|   }, [wallets]) | ||||
| 
 | ||||
|   return { wallets, resetClient } | ||||
| } | ||||
| 
 | ||||
| function getStorageKey (name, me) { | ||||
|   let storageKey = `wallet:${name}` | ||||
|   if (me) { | ||||
|     storageKey = `${storageKey}:${me.id}` | ||||
|   } | ||||
|   return storageKey | ||||
| } | ||||
| 
 | ||||
| function enableWallet (name, me) { | ||||
|   const key = getStorageKey(name, me) | ||||
|   const config = JSON.parse(window.localStorage.getItem(key)) | ||||
|   if (!config) return | ||||
|   config.enabled = true | ||||
|   window.localStorage.setItem(key, JSON.stringify(config)) | ||||
| } | ||||
| 
 | ||||
| function disableWallet (name, me) { | ||||
|   const key = getStorageKey(name, me) | ||||
|   const config = JSON.parse(window.localStorage.getItem(key)) | ||||
|   if (!config) return | ||||
|   config.enabled = false | ||||
|   window.localStorage.setItem(key, JSON.stringify(config)) | ||||
| } | ||||
							
								
								
									
										1
									
								
								wallets/lightning-address/client.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								wallets/lightning-address/client.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1 @@ | ||||
| export * from 'wallets/lightning-address' | ||||
							
								
								
									
										28
									
								
								wallets/lightning-address/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								wallets/lightning-address/index.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,28 @@ | ||||
| export const name = 'lightning-address' | ||||
| export const shortName = 'lnAddr' | ||||
| 
 | ||||
| export const fields = [ | ||||
|   { | ||||
|     name: 'address', | ||||
|     label: 'lightning address', | ||||
|     type: 'text', | ||||
|     autoComplete: 'off', | ||||
|     validate: { | ||||
|       type: 'email', | ||||
|       test: { | ||||
|         test: addr => !addr.endsWith('@stacker.news'), | ||||
|         message: 'automated withdrawals must be external' | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| ] | ||||
| 
 | ||||
| export const card = { | ||||
|   title: 'lightning address', | ||||
|   subtitle: 'autowithdraw to a lightning address', | ||||
|   badges: ['receive only', 'non-custodialish'] | ||||
| } | ||||
| 
 | ||||
| export const walletType = 'LIGHTNING_ADDRESS' | ||||
| 
 | ||||
| export const walletField = 'walletLightningAddress' | ||||
							
								
								
									
										28
									
								
								wallets/lightning-address/server.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								wallets/lightning-address/server.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,28 @@ | ||||
| import { fetchLnAddrInvoice } from '@/api/resolvers/wallet' | ||||
| import { lnAddrOptions } from '@/lib/lnurl' | ||||
| 
 | ||||
| export * from 'wallets/lightning-address' | ||||
| 
 | ||||
| export const testConnect = async ( | ||||
|   { address }, | ||||
|   { me, models, addWalletLog } | ||||
| ) => { | ||||
|   const options = await lnAddrOptions(address) | ||||
|   await addWalletLog({ wallet: { type: 'LIGHTNING_ADDRESS' }, level: 'SUCCESS', message: 'fetched payment details' }, { me, models }) | ||||
|   return options | ||||
| } | ||||
| 
 | ||||
| export const createInvoice = async ( | ||||
|   { amount, maxFee }, | ||||
|   { address }, | ||||
|   { me, models, lnd, lnService } | ||||
| ) => { | ||||
|   const res = await fetchLnAddrInvoice({ addr: address, amount, maxFee }, { | ||||
|     me, | ||||
|     models, | ||||
|     lnd, | ||||
|     lnService, | ||||
|     autoWithdraw: true | ||||
|   }) | ||||
|   return res.pr | ||||
| } | ||||
							
								
								
									
										80
									
								
								wallets/lnbits/client.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										80
									
								
								wallets/lnbits/client.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,80 @@ | ||||
| export * from 'wallets/lnbits' | ||||
| 
 | ||||
| export async function validate ({ url, adminKey }, { logger }) { | ||||
|   logger.info('trying to fetch wallet') | ||||
| 
 | ||||
|   url = url.replace(/\/+$/, '') | ||||
|   await getWallet({ url, adminKey }) | ||||
| 
 | ||||
|   logger.ok('wallet found') | ||||
| } | ||||
| 
 | ||||
| export async function sendPayment (bolt11, { url, adminKey }) { | ||||
|   url = url.replace(/\/+$/, '') | ||||
| 
 | ||||
|   const response = await postPayment(bolt11, { url, adminKey }) | ||||
| 
 | ||||
|   const checkResponse = await getPayment(response.payment_hash, { url, adminKey }) | ||||
|   if (!checkResponse.preimage) { | ||||
|     throw new Error('No preimage') | ||||
|   } | ||||
| 
 | ||||
|   const preimage = checkResponse.preimage | ||||
|   return { preimage } | ||||
| } | ||||
| 
 | ||||
| async function getWallet ({ url, adminKey }) { | ||||
|   const path = '/api/v1/wallet' | ||||
| 
 | ||||
|   const headers = new Headers() | ||||
|   headers.append('Accept', 'application/json') | ||||
|   headers.append('Content-Type', 'application/json') | ||||
|   headers.append('X-Api-Key', adminKey) | ||||
| 
 | ||||
|   const res = await fetch(url + path, { method: 'GET', headers }) | ||||
|   if (!res.ok) { | ||||
|     const errBody = await res.json() | ||||
|     throw new Error(errBody.detail) | ||||
|   } | ||||
| 
 | ||||
|   const wallet = await res.json() | ||||
|   return wallet | ||||
| } | ||||
| 
 | ||||
| async function postPayment (bolt11, { url, adminKey }) { | ||||
|   const path = '/api/v1/payments' | ||||
| 
 | ||||
|   const headers = new Headers() | ||||
|   headers.append('Accept', 'application/json') | ||||
|   headers.append('Content-Type', 'application/json') | ||||
|   headers.append('X-Api-Key', adminKey) | ||||
| 
 | ||||
|   const body = JSON.stringify({ bolt11, out: true }) | ||||
| 
 | ||||
|   const res = await fetch(url + path, { method: 'POST', headers, body }) | ||||
|   if (!res.ok) { | ||||
|     const errBody = await res.json() | ||||
|     throw new Error(errBody.detail) | ||||
|   } | ||||
| 
 | ||||
|   const payment = await res.json() | ||||
|   return payment | ||||
| } | ||||
| 
 | ||||
| async function getPayment (paymentHash, { url, adminKey }) { | ||||
|   const path = `/api/v1/payments/${paymentHash}` | ||||
| 
 | ||||
|   const headers = new Headers() | ||||
|   headers.append('Accept', 'application/json') | ||||
|   headers.append('Content-Type', 'application/json') | ||||
|   headers.append('X-Api-Key', adminKey) | ||||
| 
 | ||||
|   const res = await fetch(url + path, { method: 'GET', headers }) | ||||
|   if (!res.ok) { | ||||
|     const errBody = await res.json() | ||||
|     throw new Error(errBody.detail) | ||||
|   } | ||||
| 
 | ||||
|   const payment = await res.json() | ||||
|   return payment | ||||
| } | ||||
							
								
								
									
										28
									
								
								wallets/lnbits/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								wallets/lnbits/index.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,28 @@ | ||||
| export const name = 'lnbits' | ||||
| 
 | ||||
| export const fields = [ | ||||
|   { | ||||
|     name: 'url', | ||||
|     label: 'lnbits url', | ||||
|     type: 'text', | ||||
|     validate: { | ||||
|       type: 'url', | ||||
|       torAllowed: true | ||||
|     } | ||||
|   }, | ||||
|   { | ||||
|     name: 'adminKey', | ||||
|     label: 'admin key', | ||||
|     type: 'password', | ||||
|     validate: { | ||||
|       type: 'string', | ||||
|       length: 32 | ||||
|     } | ||||
|   } | ||||
| ] | ||||
| 
 | ||||
| export const card = { | ||||
|   title: 'LNbits', | ||||
|   subtitle: 'use [LNbits](https://lnbits.com/) for payments', | ||||
|   badges: ['send only', 'non-custodialish'] | ||||
| } | ||||
							
								
								
									
										161
									
								
								wallets/lnc/client.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										161
									
								
								wallets/lnc/client.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,161 @@ | ||||
| import { InvoiceCanceledError, InvoiceExpiredError } from '@/components/payment' | ||||
| import { bolt11Tags } from '@/lib/bolt11' | ||||
| import { Mutex } from 'async-mutex' | ||||
| export * from 'wallets/lnc' | ||||
| 
 | ||||
| async function disconnect (lnc, logger) { | ||||
|   if (lnc) { | ||||
|     try { | ||||
|       lnc.disconnect() | ||||
|       logger.info('disconnecting...') | ||||
|       // wait for lnc to disconnect before releasing the mutex
 | ||||
|       await new Promise((resolve, reject) => { | ||||
|         let counter = 0 | ||||
|         const interval = setInterval(() => { | ||||
|           if (lnc?.isConnected) { | ||||
|             if (counter++ > 100) { | ||||
|               logger.error('failed to disconnect from lnc') | ||||
|               clearInterval(interval) | ||||
|               reject(new Error('failed to disconnect from lnc')) | ||||
|             } | ||||
|             return | ||||
|           } | ||||
|           clearInterval(interval) | ||||
|           resolve() | ||||
|         }) | ||||
|       }, 50) | ||||
|     } catch (err) { | ||||
|       logger.error('failed to disconnect from lnc', err) | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export async function validate (credentials, { me, logger }) { | ||||
|   let lnc | ||||
|   try { | ||||
|     lnc = await getLNC(credentials) | ||||
| 
 | ||||
|     logger.info('connecting ...') | ||||
|     await lnc.connect() | ||||
|     logger.ok('connected') | ||||
| 
 | ||||
|     logger.info('validating permissions ...') | ||||
|     await validateNarrowPerms(lnc) | ||||
|     logger.ok('permissions ok') | ||||
| 
 | ||||
|     return lnc.credentials.credentials | ||||
|   } finally { | ||||
|     await disconnect(lnc, logger) | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| const mutex = new Mutex() | ||||
| 
 | ||||
| export async function sendPayment (bolt11, credentials, { me, status, logger }) { | ||||
|   const hash = bolt11Tags(bolt11).payment_hash | ||||
| 
 | ||||
|   return await mutex.runExclusive(async () => { | ||||
|     let lnc | ||||
|     try { | ||||
|       lnc = await getLNC(credentials) | ||||
| 
 | ||||
|       await lnc.connect() | ||||
|       const { paymentError, paymentPreimage: preimage } = | ||||
|           await lnc.lnd.lightning.sendPaymentSync({ payment_request: bolt11 }) | ||||
| 
 | ||||
|       if (paymentError) throw new Error(paymentError) | ||||
|       if (!preimage) throw new Error('No preimage in response') | ||||
| 
 | ||||
|       return { preimage } | ||||
|     } catch (err) { | ||||
|       const msg = err.message || err.toString?.() | ||||
|       if (msg.includes('invoice expired')) { | ||||
|         throw new InvoiceExpiredError(hash) | ||||
|       } | ||||
|       if (msg.includes('canceled')) { | ||||
|         throw new InvoiceCanceledError(hash) | ||||
|       } | ||||
|       throw err | ||||
|     } finally { | ||||
|       await disconnect(lnc, logger) | ||||
|     } | ||||
|   }) | ||||
| } | ||||
| 
 | ||||
| async function getLNC (credentials = {}) { | ||||
|   const { default: { default: LNC } } = await import('@lightninglabs/lnc-web') | ||||
|   return new LNC({ | ||||
|     credentialStore: new LncCredentialStore({ ...credentials, serverHost: 'mailbox.terminal.lightning.today:443' }) | ||||
|   }) | ||||
| } | ||||
| 
 | ||||
| function validateNarrowPerms (lnc) { | ||||
|   if (!lnc.hasPerms('lnrpc.Lightning.SendPaymentSync')) { | ||||
|     throw new Error('missing permission: lnrpc.Lightning.SendPaymentSync') | ||||
|   } | ||||
|   if (lnc.hasPerms('lnrpc.Lightning.SendCoins')) { | ||||
|     throw new Error('too broad permission: lnrpc.Wallet.SendCoins') | ||||
|   } | ||||
|   // TODO: need to check for more narrow permissions
 | ||||
|   // blocked by https://github.com/lightninglabs/lnc-web/issues/112
 | ||||
| } | ||||
| 
 | ||||
| // default credential store can go fuck itself
 | ||||
| class LncCredentialStore { | ||||
|   credentials = { | ||||
|     localKey: '', | ||||
|     remoteKey: '', | ||||
|     pairingPhrase: '', | ||||
|     serverHost: '' | ||||
|   } | ||||
| 
 | ||||
|   constructor (credentials = {}) { | ||||
|     this.credentials = { ...this.credentials, ...credentials } | ||||
|   } | ||||
| 
 | ||||
|   get password () { | ||||
|     return '' | ||||
|   } | ||||
| 
 | ||||
|   set password (password) { } | ||||
| 
 | ||||
|   get serverHost () { | ||||
|     return this.credentials.serverHost | ||||
|   } | ||||
| 
 | ||||
|   set serverHost (host) { | ||||
|     this.credentials.serverHost = host | ||||
|   } | ||||
| 
 | ||||
|   get pairingPhrase () { | ||||
|     return this.credentials.pairingPhrase | ||||
|   } | ||||
| 
 | ||||
|   set pairingPhrase (phrase) { | ||||
|     this.credentials.pairingPhrase = phrase | ||||
|   } | ||||
| 
 | ||||
|   get localKey () { | ||||
|     return this.credentials.localKey | ||||
|   } | ||||
| 
 | ||||
|   set localKey (key) { | ||||
|     this.credentials.localKey = key | ||||
|   } | ||||
| 
 | ||||
|   get remoteKey () { | ||||
|     return this.credentials.remoteKey | ||||
|   } | ||||
| 
 | ||||
|   set remoteKey (key) { | ||||
|     this.credentials.remoteKey = key | ||||
|   } | ||||
| 
 | ||||
|   get isPaired () { | ||||
|     return !!this.credentials.remoteKey || !!this.credentials.pairingPhrase | ||||
|   } | ||||
| 
 | ||||
|   clear () { | ||||
|     this.credentials = {} | ||||
|   } | ||||
| } | ||||
							
								
								
									
										42
									
								
								wallets/lnc/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								wallets/lnc/index.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,42 @@ | ||||
| import bip39Words from '@/lib/bip39-words' | ||||
| 
 | ||||
| export const name = 'lnc' | ||||
| 
 | ||||
| export const fields = [ | ||||
|   { | ||||
|     name: 'pairingPhrase', | ||||
|     label: 'pairing phrase', | ||||
|     type: 'password', | ||||
|     help: 'We only need permissions for the uri `/lnrpc.Lightning/SendPaymentSync`\n\nCreate a budgeted account with narrow permissions:\n\n```$ litcli accounts create --balance <budget>```\n\n```$ litcli sessions add --type custom --label <your label> --account_id <account_id> --uri /lnrpc.Lightning/SendPaymentSync```\n\nGrab the `pairing_secret_mnemonic` from the output and paste it here.', | ||||
|     validate: { | ||||
|       words: bip39Words, | ||||
|       min: 2, | ||||
|       max: 10 | ||||
|     }, | ||||
|     editable: false | ||||
|   }, | ||||
|   { | ||||
|     name: 'localKey', | ||||
|     type: 'text', | ||||
|     optional: true, | ||||
|     hidden: true | ||||
|   }, | ||||
|   { | ||||
|     name: 'remoteKey', | ||||
|     type: 'text', | ||||
|     optional: true, | ||||
|     hidden: true | ||||
|   }, | ||||
|   { | ||||
|     name: 'serverHost', | ||||
|     type: 'text', | ||||
|     optional: true, | ||||
|     hidden: true | ||||
|   } | ||||
| ] | ||||
| 
 | ||||
| export const card = { | ||||
|   title: 'LNC', | ||||
|   subtitle: 'use Lightning Node Connect for LND payments', | ||||
|   badges: ['send only', 'non-custodial', 'budgetable'] | ||||
| } | ||||
							
								
								
									
										1
									
								
								wallets/lnd/client.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								wallets/lnd/client.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1 @@ | ||||
| export * from 'wallets/lnd' | ||||
							
								
								
									
										58
									
								
								wallets/lnd/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								wallets/lnd/index.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,58 @@ | ||||
| import { isInvoicableMacaroon, isInvoiceMacaroon } from '@/lib/macaroon' | ||||
| 
 | ||||
| export const name = 'lnd' | ||||
| 
 | ||||
| export const fields = [ | ||||
|   { | ||||
|     name: 'socket', | ||||
|     label: 'grpc host and port', | ||||
|     type: 'text', | ||||
|     placeholder: '55.5.555.55:10001', | ||||
|     hint: 'tor or clearnet', | ||||
|     clear: true, | ||||
|     validate: { | ||||
|       type: 'socket' | ||||
|     } | ||||
|   }, | ||||
|   { | ||||
|     name: 'macaroon', | ||||
|     label: 'invoice macaroon', | ||||
|     help: { | ||||
|       label: 'privacy tip', | ||||
|       text: 'We accept a prebaked ***invoice.macaroon*** for your convenience. To gain better privacy, generate a new macaroon as follows:\n\n```lncli bakemacaroon invoices:write invoices:read```' | ||||
|     }, | ||||
|     type: 'text', | ||||
|     placeholder: 'AgEDbG5kAlgDChCn7YgfWX7uTkQQgXZ2uahNEgEwGhYKB2FkZHJlc3MSBHJlYWQSBXdyaXRlGhcKCGludm9pY2VzEgRyZWFkEgV3cml0ZRoPCgdvbmNoYWluEgRyZWFkAAAGIJkMBrrDV0npU90JV0TGNJPrqUD8m2QYoTDjolaL6eBs', | ||||
|     hint: 'hex or base64 encoded', | ||||
|     clear: true, | ||||
|     validate: { | ||||
|       type: 'hexOrBase64', | ||||
|       test: { | ||||
|         test: v => isInvoiceMacaroon(v) || isInvoicableMacaroon(v), | ||||
|         message: 'not an invoice macaroon or an invoicable macaroon' | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   { | ||||
|     name: 'cert', | ||||
|     label: 'cert', | ||||
|     type: 'text', | ||||
|     placeholder: 'LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNNVENDQWRpZ0F3SUJBZ0lRSHBFdFdrcGJwZHV4RVF2eVBPc3NWVEFLQmdncWhrak9QUVFEQWpBdk1SOHcKSFFZRFZRUUtFeFpzYm1RZ1lYVjBiMmRsYm1WeVlYUmxaQ0JqWlhKME1Rd3dDZ1lEVlFRREV3TmliMkl3SGhjTgpNalF3TVRBM01qQXhORE0wV2hjTk1qVXdNekF6TWpBeE5ETTBXakF2TVI4d0hRWURWUVFLRXhac2JtUWdZWFYwCmIyZGxibVZ5WVhSbFpDQmpaWEowTVF3d0NnWURWUVFERXdOaWIySXdXVEFUQmdjcWhrak9QUUlCQmdncWhrak8KUFFNQkJ3TkNBQVJUS3NMVk5oZnhqb1FLVDlkVVdDbzUzSmQwTnBuL1BtYi9LUE02M1JxbU52dFYvdFk4NjJJZwpSbE41cmNHRnBEajhUeFc2OVhIK0pTcHpjWDdlN3N0Um80SFZNSUhTTUE0R0ExVWREd0VCL3dRRUF3SUNwREFUCkJnTlZIU1VFRERBS0JnZ3JCZ0VGQlFjREFUQVBCZ05WSFJNQkFmOEVCVEFEQVFIL01CMEdBMVVkRGdRV0JCVDAKMnh3V25GeHRUNzI0MWxwZlNoNm9FWi9UMWpCN0JnTlZIUkVFZERCeWdnTmliMktDQ1d4dlkyRnNhRzl6ZElJRApZbTlpZ2d4d2IyeGhjaTF1TVMxaWIyS0NGR2h2YzNRdVpHOWphMlZ5TG1sdWRHVnlibUZzZ2dSMWJtbDRnZ3AxCmJtbDRjR0ZqYTJWMGdnZGlkV1pqYjI1dWh3Ui9BQUFCaHhBQUFBQUFBQUFBQUFBQUFBQUFBQUFCaHdTc0VnQUQKTUFvR0NDcUdTTTQ5QkFNQ0EwY0FNRVFDSUEwUTlkRXdoNXpPRnpwL3hYeHNpemh5SkxNVG5yazU1VWx1NHJPRwo4WW52QWlBVGt4U3p3Y3hZZnFscGx0UlNIbmd0NUJFcDBzcXlHL05nenBzb2pmMGNqQT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K', | ||||
|     optional: 'optional if from [CA](https://en.wikipedia.org/wiki/Certificate_authority) (e.g. voltage)', | ||||
|     hint: 'hex or base64 encoded', | ||||
|     clear: true, | ||||
|     validate: { | ||||
|       type: 'hexOrBase64' | ||||
|     } | ||||
|   } | ||||
| ] | ||||
| 
 | ||||
| export const card = { | ||||
|   title: 'LND', | ||||
|   subtitle: 'autowithdraw to your Lightning Labs node', | ||||
|   badges: ['receive only', 'non-custodial'] | ||||
| } | ||||
| 
 | ||||
| export const walletType = 'LND' | ||||
| 
 | ||||
| export const walletField = 'walletLND' | ||||
							
								
								
									
										59
									
								
								wallets/lnd/server.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								wallets/lnd/server.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,59 @@ | ||||
| import { ensureB64 } from '@/lib/format' | ||||
| import { datePivot } from '@/lib/time' | ||||
| import { authenticatedLndGrpc, createInvoice as lndCreateInvoice } from 'ln-service' | ||||
| import { addWalletLog } from '@/api/resolvers/wallet' | ||||
| 
 | ||||
| export * from 'wallets/lnd' | ||||
| 
 | ||||
| export const testConnect = async ( | ||||
|   { cert, macaroon, socket }, | ||||
|   { me, models } | ||||
| ) => { | ||||
|   try { | ||||
|     cert = ensureB64(cert) | ||||
|     macaroon = ensureB64(macaroon) | ||||
| 
 | ||||
|     const { lnd } = await authenticatedLndGrpc({ | ||||
|       cert, | ||||
|       macaroon, | ||||
|       socket | ||||
|     }) | ||||
| 
 | ||||
|     const inv = await lndCreateInvoice({ | ||||
|       description: 'SN connection test', | ||||
|       lnd, | ||||
|       tokens: 0, | ||||
|       expires_at: new Date() | ||||
|     }) | ||||
| 
 | ||||
|     // we wrap both calls in one try/catch since connection attempts happen on RPC calls
 | ||||
|     await addWalletLog({ wallet: { type: 'LND' }, level: 'SUCCESS', message: 'connected to LND' }, { me, models }) | ||||
| 
 | ||||
|     return inv | ||||
|   } catch (err) { | ||||
|     // LND errors are in this shape: [code, type, { err: { code, details, metadata } }]
 | ||||
|     const details = err[2]?.err?.details || err.message || err.toString?.() | ||||
|     throw new Error(details) | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export const createInvoice = async ( | ||||
|   { amount }, | ||||
|   { cert, macaroon, socket }, | ||||
|   { me } | ||||
| ) => { | ||||
|   const { lnd } = await authenticatedLndGrpc({ | ||||
|     cert, | ||||
|     macaroon, | ||||
|     socket | ||||
|   }) | ||||
| 
 | ||||
|   const invoice = await lndCreateInvoice({ | ||||
|     description: me.hideInvoiceDesc ? undefined : 'autowithdraw to LND from SN', | ||||
|     lnd, | ||||
|     tokens: amount, | ||||
|     expires_at: datePivot(new Date(), { seconds: 360 }) | ||||
|   }) | ||||
| 
 | ||||
|   return invoice.request | ||||
| } | ||||
							
								
								
									
										118
									
								
								wallets/nwc/client.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										118
									
								
								wallets/nwc/client.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,118 @@ | ||||
| import { parseNwcUrl } from '@/lib/url' | ||||
| import { Relay, finalizeEvent, nip04 } from 'nostr-tools' | ||||
| 
 | ||||
| export * from 'wallets/nwc' | ||||
| 
 | ||||
| export async function validate ({ nwcUrl }, { logger }) { | ||||
|   const { relayUrl, walletPubkey } = parseNwcUrl(nwcUrl) | ||||
| 
 | ||||
|   logger.info(`requesting info event from ${relayUrl}`) | ||||
|   const relay = await Relay | ||||
|     .connect(relayUrl) | ||||
|     .catch(() => { | ||||
|       // NOTE: passed error is undefined for some reason
 | ||||
|       const msg = `failed to connect to ${relayUrl}` | ||||
|       logger.error(msg) | ||||
|       throw new Error(msg) | ||||
|     }) | ||||
|   logger.ok(`connected to ${relayUrl}`) | ||||
| 
 | ||||
|   try { | ||||
|     await new Promise((resolve, reject) => { | ||||
|       let found = false | ||||
|       const sub = relay.subscribe([ | ||||
|         { | ||||
|           kinds: [13194], | ||||
|           authors: [walletPubkey] | ||||
|         } | ||||
|       ], { | ||||
|         onevent (event) { | ||||
|           found = true | ||||
|           logger.ok(`received info event from ${relayUrl}`) | ||||
|           resolve(event) | ||||
|         }, | ||||
|         onclose (reason) { | ||||
|           if (!['closed by caller', 'relay connection closed by us'].includes(reason)) { | ||||
|             // only log if not closed by us (caller)
 | ||||
|             const msg = 'connection closed: ' + (reason || 'unknown reason') | ||||
|             logger.error(msg) | ||||
|             reject(new Error(msg)) | ||||
|           } | ||||
|         }, | ||||
|         oneose () { | ||||
|           if (!found) { | ||||
|             const msg = 'EOSE received without info event' | ||||
|             logger.error(msg) | ||||
|             reject(new Error(msg)) | ||||
|           } | ||||
|           sub?.close() | ||||
|         } | ||||
|       }) | ||||
|     }) | ||||
|   } finally { | ||||
|     // For some reason, this throws 'WebSocket is already in CLOSING or CLOSED state'
 | ||||
|     // even though relay connection is still open here
 | ||||
|     relay?.close()?.catch() | ||||
|     if (relay) logger.info(`closed connection to ${relayUrl}`) | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export async function sendPayment (bolt11, { nwcUrl }, { logger }) { | ||||
|   const { relayUrl, walletPubkey, secret } = parseNwcUrl(nwcUrl) | ||||
| 
 | ||||
|   const relay = await Relay.connect(relayUrl).catch(() => { | ||||
|     // NOTE: passed error is undefined for some reason
 | ||||
|     throw new Error(`failed to connect to ${relayUrl}`) | ||||
|   }) | ||||
|   logger.ok(`connected to ${relayUrl}`) | ||||
| 
 | ||||
|   try { | ||||
|     const ret = await new Promise(function (resolve, reject) { | ||||
|       (async function () { | ||||
|         const payload = { | ||||
|           method: 'pay_invoice', | ||||
|           params: { invoice: bolt11 } | ||||
|         } | ||||
|         const content = await nip04.encrypt(secret, walletPubkey, JSON.stringify(payload)) | ||||
| 
 | ||||
|         const request = finalizeEvent({ | ||||
|           kind: 23194, | ||||
|           created_at: Math.floor(Date.now() / 1000), | ||||
|           tags: [['p', walletPubkey]], | ||||
|           content | ||||
|         }, secret) | ||||
|         await relay.publish(request) | ||||
| 
 | ||||
|         const filter = { | ||||
|           kinds: [23195], | ||||
|           authors: [walletPubkey], | ||||
|           '#e': [request.id] | ||||
|         } | ||||
|         relay.subscribe([filter], { | ||||
|           async onevent (response) { | ||||
|             try { | ||||
|               const content = JSON.parse(await nip04.decrypt(secret, walletPubkey, response.content)) | ||||
|               if (content.error) return reject(new Error(content.error.message)) | ||||
|               if (content.result) return resolve({ preimage: content.result.preimage }) | ||||
|             } catch (err) { | ||||
|               return reject(err) | ||||
|             } | ||||
|           }, | ||||
|           onclose (reason) { | ||||
|             if (!['closed by caller', 'relay connection closed by us'].includes(reason)) { | ||||
|               // only log if not closed by us (caller)
 | ||||
|               const msg = 'connection closed: ' + (reason || 'unknown reason') | ||||
|               reject(new Error(msg)) | ||||
|             } | ||||
|           } | ||||
|         }) | ||||
|       })().catch(reject) | ||||
|     }) | ||||
|     return ret | ||||
|   } finally { | ||||
|     // For some reason, this throws 'WebSocket is already in CLOSING or CLOSED state'
 | ||||
|     // even though relay connection is still open here
 | ||||
|     relay?.close()?.catch() | ||||
|     if (relay) logger.info(`closed connection to ${relayUrl}`) | ||||
|   } | ||||
| } | ||||
							
								
								
									
										43
									
								
								wallets/nwc/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								wallets/nwc/index.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,43 @@ | ||||
| import { NOSTR_PUBKEY_HEX } from '@/lib/nostr' | ||||
| import { parseNwcUrl } from '@/lib/url' | ||||
| import { string } from 'yup' | ||||
| 
 | ||||
| export const name = 'nwc' | ||||
| 
 | ||||
| export const fields = [ | ||||
|   { | ||||
|     name: 'nwcUrl', | ||||
|     label: 'connection', | ||||
|     type: 'password', | ||||
|     validate: { | ||||
|       type: 'string', | ||||
|       test: async (nwcUrl, context) => { | ||||
|         // run validation in sequence to control order of errors
 | ||||
|         // inspired by https://github.com/jquense/yup/issues/851#issuecomment-1049705180
 | ||||
|         try { | ||||
|           await string().required('required').validate(nwcUrl) | ||||
|           await string().matches(/^nostr\+?walletconnect:\/\//, { message: 'must start with nostr+walletconnect://' }).validate(nwcUrl) | ||||
|           let relayUrl, walletPubkey, secret | ||||
|           try { | ||||
|             ({ relayUrl, walletPubkey, secret } = parseNwcUrl(nwcUrl)) | ||||
|           } catch { | ||||
|             // invalid URL error. handle as if pubkey validation failed to not confuse user.
 | ||||
|             throw new Error('pubkey must be 64 hex chars') | ||||
|           } | ||||
|           await string().required('pubkey required').trim().matches(NOSTR_PUBKEY_HEX, 'pubkey must be 64 hex chars').validate(walletPubkey) | ||||
|           await string().required('relay url required').trim().wss('relay must use wss://').validate(relayUrl) | ||||
|           await string().required('secret required').trim().matches(/^[0-9a-fA-F]{64}$/, 'secret must be 64 hex chars').validate(secret) | ||||
|         } catch (err) { | ||||
|           return context.createError({ message: err.message }) | ||||
|         } | ||||
|         return true | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| ] | ||||
| 
 | ||||
| export const card = { | ||||
|   title: 'NWC', | ||||
|   subtitle: 'use Nostr Wallet Connect for payments', | ||||
|   badges: ['send only', 'non-custodialish'] | ||||
| } | ||||
							
								
								
									
										3
									
								
								wallets/package.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								wallets/package.json
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,3 @@ | ||||
| { | ||||
|     "type": "module" | ||||
| } | ||||
							
								
								
									
										5
									
								
								wallets/server.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								wallets/server.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,5 @@ | ||||
| import * as lnd from 'wallets/lnd/server' | ||||
| import * as cln from 'wallets/cln/server' | ||||
| import * as lnAddr from 'wallets/lightning-address/server' | ||||
| 
 | ||||
| export default [lnd, cln, lnAddr] | ||||
| @ -1,9 +1,6 @@ | ||||
| import { authenticatedLndGrpc, createInvoice } from 'ln-service' | ||||
| import { msatsToSats, satsToMsats } from '@/lib/format' | ||||
| import { 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( | ||||
|           { 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 | ||||
|       const { walletType, walletField, createInvoice } = w | ||||
|       return await autowithdraw( | ||||
|         { walletType, walletField, createInvoice }, | ||||
|         { amount, maxFee }, | ||||
|         { 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 | ||||
|     } | ||||
|   }) | ||||
| 
 | ||||
|   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 }) | ||||
|   const bolt11 = await walletCreateInvoice( | ||||
|     { amount, maxFee }, | ||||
|     wallet[walletField], | ||||
|     { | ||||
|       me, | ||||
|       models, | ||||
|       lnd | ||||
|     }) | ||||
| 
 | ||||
|   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…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user