Compare commits

..

No commits in common. "454ad26bd792633fa686c33696b458854f6c0f69" and "3863edb8718045285f52c013d06842b870ea719d" have entirely different histories.

28 changed files with 237 additions and 447 deletions

View File

@ -160,8 +160,3 @@ TOR_PROXY=http://127.0.0.1:7050/
# lnbits # lnbits
LNBITS_WEB_PORT=5001 LNBITS_WEB_PORT=5001
# CPU shares for each category
CPU_SHARES_IMPORTANT=1024
CPU_SHARES_MODERATE=512
CPU_SHARES_LOW=256

View File

@ -1,7 +1,7 @@
import { ANON_ITEM_SPAM_INTERVAL, ITEM_SPAM_INTERVAL, USER_ID } from '@/lib/constants' import { ANON_ITEM_SPAM_INTERVAL, ITEM_SPAM_INTERVAL, USER_ID } from '@/lib/constants'
import { notifyItemMention, notifyItemParents, notifyMention, notifyTerritorySubscribers, notifyUserSubscribers } from '@/lib/webPush' import { notifyItemMention, notifyItemParents, notifyMention, notifyTerritorySubscribers, notifyUserSubscribers } from '@/lib/webPush'
import { getItemMentions, getMentions, performBotBehavior } from './lib/item' import { getItemMentions, getMentions, performBotBehavior } from './lib/item'
import { msatsToSats, satsToMsats } from '@/lib/format' import { satsToMsats } from '@/lib/format'
export const anonable = true export const anonable = true
export const supportsPessimism = true export const supportsPessimism = true
@ -51,7 +51,8 @@ export async function perform (args, context) {
itemActs.push({ itemActs.push({
msats: cost - boostMsats, act: 'FEE', userId: data.userId, ...invoiceData msats: cost - boostMsats, act: 'FEE', userId: data.userId, ...invoiceData
}) })
data.cost = msatsToSats(cost - boostMsats) } else {
data.freebie = true
} }
const mentions = await getMentions(args, context) const mentions = await getMentions(args, context)

View File

@ -30,12 +30,12 @@ function commentsOrderByClause (me, models, sort) {
return `ORDER BY COALESCE( return `ORDER BY COALESCE(
personal_hot_score, personal_hot_score,
${orderByNumerator(models, 0)}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3)) DESC NULLS LAST, ${orderByNumerator(models, 0)}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3)) DESC NULLS LAST,
"Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC` "Item".msats DESC, ("Item".freebie IS FALSE) DESC, "Item".id DESC`
} else { } else {
if (sort === 'top') { if (sort === 'top') {
return `ORDER BY ${orderByNumerator(models, 0)} DESC NULLS LAST, "Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC` return `ORDER BY ${orderByNumerator(models, 0)} DESC NULLS LAST, "Item".msats DESC, ("Item".freebie IS FALSE) DESC, "Item".id DESC`
} else { } else {
return `ORDER BY ${orderByNumerator(models, 0)}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3) DESC NULLS LAST, "Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC` return `ORDER BY ${orderByNumerator(models, 0)}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3) DESC NULLS LAST, "Item".msats DESC, ("Item".freebie IS FALSE) DESC, "Item".id DESC`
} }
} }
} }
@ -225,16 +225,22 @@ export async function filterClause (me, models, type) {
// handle freebies // handle freebies
// by default don't include freebies unless they have upvotes // by default don't include freebies unless they have upvotes
let investmentClause = '("Item".cost + "Item".boost + ("Item".msats / 1000)) >= 10' let freebieClauses = ['NOT "Item".freebie', '"Item"."weightedVotes" - "Item"."weightedDownVotes" > 0']
if (me) { if (me) {
const user = await models.user.findUnique({ where: { id: me.id } }) const user = await models.user.findUnique({ where: { id: me.id } })
// wild west mode has everything
investmentClause = `(("Item".cost + "Item".boost + ("Item".msats / 1000)) >= ${user.satsFilter} OR "Item"."userId" = ${me.id})`
if (user.wildWestMode) { if (user.wildWestMode) {
return investmentClause return ''
} }
// greeter mode includes freebies if feebies haven't been flagged
if (user.greeterMode) {
freebieClauses = ['NOT "Item".freebie', '"Item"."weightedVotes" - "Item"."weightedDownVotes" >= 0']
}
// always include if it's mine
freebieClauses.push(`"Item"."userId" = ${me.id}`)
} }
const freebieClause = '(' + freebieClauses.join(' OR ') + ')'
// handle outlawed // handle outlawed
// if the item is above the threshold or is mine // if the item is above the threshold or is mine
@ -244,7 +250,7 @@ export async function filterClause (me, models, type) {
} }
const outlawClause = '(' + outlawClauses.join(' OR ') + ')' const outlawClause = '(' + outlawClauses.join(' OR ') + ')'
return [investmentClause, outlawClause] return [freebieClause, outlawClause]
} }
function typeClause (type) { function typeClause (type) {
@ -262,7 +268,7 @@ function typeClause (type) {
case 'comments': case 'comments':
return '"Item"."parentId" IS NOT NULL' return '"Item"."parentId" IS NOT NULL'
case 'freebies': case 'freebies':
return '"Item".cost = 0' return '"Item".freebie'
case 'outlawed': case 'outlawed':
return `"Item"."weightedVotes" - "Item"."weightedDownVotes" <= -${ITEM_FILTER_THRESHOLD} OR "Item".outlawed` return `"Item"."weightedVotes" - "Item"."weightedDownVotes" <= -${ITEM_FILTER_THRESHOLD} OR "Item".outlawed`
case 'borderland': case 'borderland':
@ -464,10 +470,10 @@ export default {
'"Item".bio = false', '"Item".bio = false',
activeOrMine(me), activeOrMine(me),
await filterClause(me, models, type))} await filterClause(me, models, type))}
ORDER BY ${orderByNumerator(models, 0)}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3) DESC NULLS LAST, "Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC ORDER BY ${orderByNumerator(models, 0)}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3) DESC NULLS LAST, "Item".msats DESC, ("Item".freebie IS FALSE) DESC, "Item".id DESC
OFFSET $1 OFFSET $1
LIMIT $2`, LIMIT $2`,
orderBy: `ORDER BY ${orderByNumerator(models, 0)}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3) DESC NULLS LAST, "Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC` orderBy: `ORDER BY ${orderByNumerator(models, 0)}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3) DESC NULLS LAST, "Item".msats DESC, ("Item".freebie IS FALSE) DESC, "Item".id DESC`
}, decodedCursor.offset, limit, ...subArr) }, decodedCursor.offset, limit, ...subArr)
} }
@ -1048,9 +1054,6 @@ export default {
freedFreebie: async (item) => { freedFreebie: async (item) => {
return item.weightedVotes - item.weightedDownVotes > 0 return item.weightedVotes - item.weightedDownVotes > 0
}, },
freebie: async (item) => {
return item.cost === 0
},
meSats: async (item, args, { me, models }) => { meSats: async (item, args, { me, models }) => {
if (!me) return 0 if (!me) return 0
if (typeof item.meMsats !== 'undefined') { if (typeof item.meMsats !== 'undefined') {
@ -1252,7 +1255,7 @@ export const updateItem = async (parent, { sub: subName, forward, ...item }, { m
const differentSub = subName && old.subName !== subName const differentSub = subName && old.subName !== subName
if (differentSub) { if (differentSub) {
const sub = await models.sub.findUnique({ where: { name: subName } }) const sub = await models.sub.findUnique({ where: { name: subName } })
if (old.cost === 0) { if (old.freebie) {
if (!sub.allowFreebies) { if (!sub.allowFreebies) {
throw new GraphQLError(`~${subName} does not allow freebies`, { extensions: { code: 'BAD_INPUT' } }) throw new GraphQLError(`~${subName} does not allow freebies`, { extensions: { code: 'BAD_INPUT' } })
} }

View File

@ -5,7 +5,7 @@ import serialize from './serial'
import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor' import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
import { SELECT, itemQueryWithMeta } from './item' import { SELECT, itemQueryWithMeta } from './item'
import { msatsToSats, msatsToSatsDecimal } from '@/lib/format' import { msatsToSats, msatsToSatsDecimal } from '@/lib/format'
import { amountSchema, ssValidate, withdrawlSchema, lnAddrSchema, walletValidate } from '@/lib/validate' import { amountSchema, ssValidate, withdrawlSchema, lnAddrSchema, formikValidate } from '@/lib/validate'
import { ANON_BALANCE_LIMIT_MSATS, ANON_INV_PENDING_LIMIT, USER_ID, BALANCE_LIMIT_MSATS, INVOICE_RETENTION_DAYS, INV_PENDING_LIMIT, USER_IDS_BALANCE_NO_LIMIT } from '@/lib/constants' import { 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 { datePivot } from '@/lib/time'
import assertGofacYourself from './ofac' import assertGofacYourself from './ofac'
@ -19,14 +19,20 @@ import { lnAddrOptions } from '@/lib/lnurl'
function injectResolvers (resolvers) { function injectResolvers (resolvers) {
console.group('injected GraphQL resolvers:') console.group('injected GraphQL resolvers:')
for (const w of walletDefs) { for (const w of walletDefs) {
const resolverName = generateResolverName(w.walletField) const { fieldValidation, walletType, walletField, testConnectServer } = w
const resolverName = generateResolverName(walletField)
console.log(resolverName) console.log(resolverName)
// check if wallet uses the form-level validation built into Formik or a Yup schema
const validateArgs = typeof fieldValidation === 'function'
? { formikValidate: fieldValidation }
: { schema: fieldValidation }
resolvers.Mutation[resolverName] = async (parent, { settings, ...data }, { me, models }) => { resolvers.Mutation[resolverName] = async (parent, { settings, ...data }, { me, models }) => {
await walletValidate(w, { ...data, ...settings })
return await upsertWallet({ return await upsertWallet({
wallet: { field: w.walletField, type: w.walletType }, ...validateArgs,
testConnectServer: (data) => w.testConnectServer(data, { me, models }) wallet: { field: walletField, type: walletType },
testConnectServer: (data) => testConnectServer(data, { me, models })
}, { settings, data }, { me, models }) }, { settings, data }, { me, models })
} }
} }
@ -347,7 +353,7 @@ const resolvers = {
}, },
WalletDetails: { WalletDetails: {
__resolveType (wallet) { __resolveType (wallet) {
return wallet.address ? 'WalletLNAddr' : wallet.macaroon ? 'WalletLND' : wallet.rune ? 'WalletCLN' : 'WalletLNbits' return wallet.address ? 'WalletLNAddr' : wallet.macaroon ? 'WalletLND' : 'WalletCLN'
} }
}, },
Mutation: { Mutation: {
@ -456,8 +462,7 @@ const resolvers = {
await models.$transaction([ await models.$transaction([
models.wallet.delete({ where: { userId: me.id, id: Number(id) } }), models.wallet.delete({ where: { userId: me.id, id: Number(id) } }),
models.walletLog.create({ data: { userId: me.id, wallet: wallet.type, level: 'INFO', message: 'receives disabled' } }), models.walletLog.create({ data: { userId: me.id, wallet: wallet.type, level: 'SUCCESS', message: 'wallet detached' } })
models.walletLog.create({ data: { userId: me.id, wallet: wallet.type, level: 'SUCCESS', message: 'wallet detached for receives' } })
]) ])
return true return true
@ -552,20 +557,26 @@ export const addWalletLog = async ({ wallet, level, message }, { me, models }) =
} }
async function upsertWallet ( async function upsertWallet (
{ wallet, testConnectServer }, { settings, data }, { me, models }) { { schema, formikValidate: validate, wallet, testConnectServer }, { settings, data }, { me, models }) {
if (!me) { if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
} }
assertApiKeyNotPermitted({ me }) assertApiKeyNotPermitted({ me })
if (schema) {
await ssValidate(schema, { ...data, ...settings }, { me, models })
}
if (validate) {
await formikValidate(validate, { ...data, ...settings })
}
if (testConnectServer) { if (testConnectServer) {
try { try {
await testConnectServer(data) await testConnectServer(data)
} catch (err) { } catch (err) {
console.error(err) console.error(err)
const message = 'failed to create test invoice: ' + (err.message || err.toString?.()) const message = err.message || err.toString?.()
await addWalletLog({ wallet, level: 'ERROR', message }, { me, models }) await addWalletLog({ wallet, level: 'ERROR', message: 'failed to attach: ' + message }, { me, models })
await addWalletLog({ wallet, level: 'INFO', message: 'receives disabled' }, { me, models })
throw new GraphQLError(message, { extensions: { code: 'BAD_INPUT' } }) throw new GraphQLError(message, { extensions: { code: 'BAD_INPUT' } })
} }
} }
@ -621,7 +632,7 @@ async function upsertWallet (
userId: me.id, userId: me.id,
wallet: wallet.type, wallet: wallet.type,
level: 'SUCCESS', level: 'SUCCESS',
message: id ? 'receive details updated' : 'wallet attached for receives' message: id ? 'wallet updated' : 'wallet attached'
} }
}), }),
models.walletLog.create({ models.walletLog.create({
@ -629,7 +640,7 @@ async function upsertWallet (
userId: me.id, userId: me.id,
wallet: wallet.type, wallet: wallet.type,
level: enabled ? 'SUCCESS' : 'INFO', level: enabled ? 'SUCCESS' : 'INFO',
message: enabled ? 'receives enabled' : 'receives disabled' message: enabled ? 'wallet enabled' : 'wallet disabled'
} }
}) })
) )

View File

@ -142,11 +142,7 @@ export function getGetServerSideProps (
const { data: { me } } = await client.query({ query: ME }) const { data: { me } } = await client.query({ query: ME })
if (authRequired && !me) { if (authRequired && !me) {
let callback = process.env.NEXT_PUBLIC_URL + req.url const callback = process.env.NEXT_PUBLIC_URL + req.url
// On client-side routing, the callback is a NextJS URL
// so we need to remove the NextJS stuff.
// Example: /_next/data/development/territory.json
callback = callback.replace(/\/_next\/data\/\w+\//, '/').replace(/\.json$/, '')
return { return {
redirect: { redirect: {
destination: `/signup?callbackUrl=${encodeURIComponent(callback)}` destination: `/signup?callbackUrl=${encodeURIComponent(callback)}`

View File

@ -71,7 +71,6 @@ export default gql`
diagnostics: Boolean! diagnostics: Boolean!
noReferralLinks: Boolean! noReferralLinks: Boolean!
fiatCurrency: String! fiatCurrency: String!
satsFilter: Int!
greeterMode: Boolean! greeterMode: Boolean!
hideBookmarks: Boolean! hideBookmarks: Boolean!
hideCowboyHat: Boolean! hideCowboyHat: Boolean!
@ -141,7 +140,6 @@ export default gql`
diagnostics: Boolean! diagnostics: Boolean!
noReferralLinks: Boolean! noReferralLinks: Boolean!
fiatCurrency: String! fiatCurrency: String!
satsFilter: Int!
greeterMode: Boolean! greeterMode: Boolean!
hideBookmarks: Boolean! hideBookmarks: Boolean!
hideCowboyHat: Boolean! hideCowboyHat: Boolean!

View File

@ -2,22 +2,19 @@ import { gql } from 'graphql-tag'
import { generateResolverName } from '@/lib/wallet' import { generateResolverName } from '@/lib/wallet'
import walletDefs from 'wallets/server' import walletDefs from 'wallets/server'
import { isServerField } from 'wallets'
function injectTypeDefs (typeDefs) { function injectTypeDefs (typeDefs) {
console.group('injected GraphQL type defs:') console.group('injected GraphQL type defs:')
const injected = walletDefs.map( const injected = walletDefs.map(
(w) => { (w) => {
let args = 'id: ID, ' let args = 'id: ID, '
args += w.fields args += w.fields.map(f => {
.filter(isServerField) let arg = `${f.name}: String`
.map(f => { if (!f.optional) {
let arg = `${f.name}: String` arg += '!'
if (!f.optional) { }
arg += '!' return arg
} }).join(', ')
return arg
}).join(', ')
args += ', settings: AutowithdrawSettings!' args += ', settings: AutowithdrawSettings!'
const resolverName = generateResolverName(w.walletField) const resolverName = generateResolverName(w.walletField)
const typeDef = `${resolverName}(${args}): Boolean` const typeDef = `${resolverName}(${args}): Boolean`
@ -77,12 +74,7 @@ const typeDefs = `
cert: String cert: String
} }
type WalletLNbits { union WalletDetails = WalletLNAddr | WalletLND | WalletCLN
url: String!
invoiceKey: String!
}
union WalletDetails = WalletLNAddr | WalletLND | WalletCLN | WalletLNbits
input AutowithdrawSettings { input AutowithdrawSettings {
autoWithdrawThreshold: Int! autoWithdrawThreshold: Int!

View File

@ -118,5 +118,4 @@ takitakitanana,issue,,#1257,good-first-issue,,,,2k,takitakitanana@stacker.news,2
SatsAllDay,pr,#1263,#1112,medium,,,1,225k,weareallsatoshi@getalby.com,2024-07-31 SatsAllDay,pr,#1263,#1112,medium,,,1,225k,weareallsatoshi@getalby.com,2024-07-31
OneOneSeven117,issue,#1272,#1268,easy,,,,10k,OneOneSeven@stacker.news,2024-07-31 OneOneSeven117,issue,#1272,#1268,easy,,,,10k,OneOneSeven@stacker.news,2024-07-31
aniskhalfallah,pr,#1264,#1226,good-first-issue,,,,20k,aniskhalfallah@stacker.news,2024-07-31 aniskhalfallah,pr,#1264,#1226,good-first-issue,,,,20k,aniskhalfallah@stacker.news,2024-07-31
Gudnessuche,issue,#1264,#1226,good-first-issue,,,,2k,everythingsatoshi@getalby.com,2024-08-10 Gudnessuche,issue,#1264,#1226,good-first-issue,,,,2k,???,???
aniskhalfallah,pr,#1289,,easy,,,,100k,aniskhalfallah@blink.sv,2024-08-12

1 name type pr id issue ids difficulty priority changes requested notes amount receive method date paid
118 SatsAllDay pr #1263 #1112 medium 1 225k weareallsatoshi@getalby.com 2024-07-31
119 OneOneSeven117 issue #1272 #1268 easy 10k OneOneSeven@stacker.news 2024-07-31
120 aniskhalfallah pr #1264 #1226 good-first-issue 20k aniskhalfallah@stacker.news 2024-07-31
121 Gudnessuche issue #1264 #1226 good-first-issue 2k everythingsatoshi@getalby.com ??? 2024-08-10 ???
aniskhalfallah pr #1289 easy 100k aniskhalfallah@blink.sv 2024-08-12

View File

@ -25,15 +25,10 @@ export function AutowithdrawSettings ({ wallet }) {
setSendThreshold(Math.max(Math.floor(threshold / 10), 1)) setSendThreshold(Math.max(Math.floor(threshold / 10), 1))
}, [autoWithdrawThreshold]) }, [autoWithdrawThreshold])
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
return ( return (
<> <>
<Checkbox <Checkbox
disabled={mounted && !wallet.isConfigured} disabled={!wallet.isConfigured}
label='enabled' label='enabled'
id='enabled' id='enabled'
name='enabled' name='enabled'

View File

@ -97,7 +97,7 @@ export default function Comment ({
}) { }) {
const [edit, setEdit] = useState() const [edit, setEdit] = useState()
const me = useMe() const me = useMe()
const isHiddenFreebie = me?.privates?.satsFilter !== 0 && !item.mine && item.freebie && !item.freedFreebie const isHiddenFreebie = !me?.privates?.wildWestMode && !me?.privates?.greeterMode && !item.mine && item.freebie && !item.freedFreebie
const [collapse, setCollapse] = useState( const [collapse, setCollapse] = useState(
(isHiddenFreebie || item?.user?.meMute || (item?.outlawed && !me?.privates?.wildWestMode)) && !includeParent (isHiddenFreebie || item?.user?.meMute || (item?.outlawed && !me?.privates?.wildWestMode)) && !includeParent
? 'yep' ? 'yep'

View File

@ -243,6 +243,9 @@ export function useWalletLogger (wallet) {
return return
} }
// don't store logs for receiving wallets on client since logs are stored on server
if (wallet.walletType) return
// TODO: // TODO:
// also send this to us if diagnostics was enabled, // also send this to us if diagnostics was enabled,
// very similar to how the service worker logger works. // very similar to how the service worker logger works.

View File

@ -35,7 +35,6 @@ services:
- db:/var/lib/postgresql/data - db:/var/lib/postgresql/data
labels: labels:
CONNECT: "localhost:5431" CONNECT: "localhost:5431"
cpu_shares: "${CPU_SHARES_IMPORTANT}"
app: app:
container_name: app container_name: app
stdin_open: true stdin_open: true
@ -59,7 +58,6 @@ services:
- ./:/app - ./:/app
labels: labels:
CONNECT: "localhost:3000" CONNECT: "localhost:3000"
cpu_shares: "${CPU_SHARES_IMPORTANT}"
capture: capture:
container_name: capture container_name: capture
build: build:
@ -81,7 +79,6 @@ services:
- "5678:5678" - "5678:5678"
labels: labels:
CONNECT: "localhost:5678" CONNECT: "localhost:5678"
cpu_shares: "${CPU_SHARES_LOW}"
worker: worker:
container_name: worker container_name: worker
build: build:
@ -100,7 +97,6 @@ services:
entrypoint: ["/bin/sh", "-c"] entrypoint: ["/bin/sh", "-c"]
command: command:
- npm run worker:dev - npm run worker:dev
cpu_shares: "${CPU_SHARES_IMPORTANT}"
imgproxy: imgproxy:
container_name: imgproxy container_name: imgproxy
image: darthsim/imgproxy:v3.23.0 image: darthsim/imgproxy:v3.23.0
@ -117,7 +113,6 @@ services:
- "8080" - "8080"
labels: labels:
- "CONNECT=localhost:3001" - "CONNECT=localhost:3001"
cpu_shares: "${CPU_SHARES_LOW}"
s3: s3:
container_name: s3 container_name: s3
image: localstack/localstack:s3-latest image: localstack/localstack:s3-latest
@ -143,7 +138,6 @@ services:
- './docker/s3/cors.json:/etc/localstack/init/ready.d/cors.json' - './docker/s3/cors.json:/etc/localstack/init/ready.d/cors.json'
labels: labels:
- "CONNECT=localhost:4566" - "CONNECT=localhost:4566"
cpu_shares: "${CPU_SHARES_LOW}"
opensearch: opensearch:
image: opensearchproject/opensearch:2.12.0 image: opensearchproject/opensearch:2.12.0
container_name: opensearch container_name: opensearch
@ -183,7 +177,6 @@ services:
echo "OpenSearch index created." echo "OpenSearch index created."
fg fg
' '
cpu_shares: "${CPU_SHARES_LOW}"
os-dashboard: os-dashboard:
image: opensearchproject/opensearch-dashboards:2.12.0 image: opensearchproject/opensearch-dashboards:2.12.0
container_name: os-dashboard container_name: os-dashboard
@ -205,7 +198,6 @@ services:
- opensearch - opensearch
labels: labels:
CONNECT: "localhost:5601" CONNECT: "localhost:5601"
cpu_shares: "${CPU_SHARES_LOW}"
bitcoin: bitcoin:
image: polarlightning/bitcoind:26.0 image: polarlightning/bitcoind:26.0
container_name: bitcoin container_name: bitcoin
@ -262,7 +254,6 @@ services:
bitcoin-cli -chain=regtest -rpcport=${RPC_PORT} -rpcuser=${RPC_USER} -rpcpassword=${RPC_PASS} generatetoaddress 1 ${STACKER_CLN_ADDR} bitcoin-cli -chain=regtest -rpcport=${RPC_PORT} -rpcuser=${RPC_USER} -rpcpassword=${RPC_PASS} generatetoaddress 1 ${STACKER_CLN_ADDR}
fi fi
' '
cpu_shares: "${CPU_SHARES_MODERATE}"
sn_lnd: sn_lnd:
build: build:
context: ./docker/lnd context: ./docker/lnd
@ -320,7 +311,6 @@ services:
--min_confs 0 --local_amt=1000000000 --push_amt=500000000 --min_confs 0 --local_amt=1000000000 --push_amt=500000000
fi fi
" "
cpu_shares: "${CPU_SHARES_MODERATE}"
stacker_lnd: stacker_lnd:
build: build:
context: ./docker/lnd context: ./docker/lnd
@ -380,7 +370,6 @@ services:
--min_confs 0 --local_amt=1000000000 --push_amt=500000000 --min_confs 0 --local_amt=1000000000 --push_amt=500000000
fi fi
" "
cpu_shares: "${CPU_SHARES_MODERATE}"
litd: litd:
container_name: litd container_name: litd
build: build:
@ -415,7 +404,6 @@ services:
- '--loop.server.host=test.swap.lightning.today:11010' - '--loop.server.host=test.swap.lightning.today:11010'
labels: labels:
CONNECT: "localhost:8443" CONNECT: "localhost:8443"
cpu_shares: "${CPU_SHARES_MODERATE}"
stacker_cln: stacker_cln:
build: build:
context: ./docker/cln context: ./docker/cln
@ -458,7 +446,6 @@ services:
amount=1000000000 push_msat=500000000000 minconf=0 amount=1000000000 push_msat=500000000000 minconf=0
fi fi
" "
cpu_shares: "${CPU_SHARES_MODERATE}"
channdler: channdler:
image: mcuadros/ofelia:latest image: mcuadros/ofelia:latest
container_name: channdler container_name: channdler
@ -473,7 +460,6 @@ services:
command: daemon --docker -f label=com.docker.compose.project=${COMPOSE_PROJECT_NAME} command: daemon --docker -f label=com.docker.compose.project=${COMPOSE_PROJECT_NAME}
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro - /var/run/docker.sock:/var/run/docker.sock:ro
cpu_shares: "${CPU_SHARES_LOW}"
mailhog: mailhog:
image: mailhog/mailhog:latest image: mailhog/mailhog:latest
container_name: mailhog container_name: mailhog
@ -490,7 +476,6 @@ services:
- app - app
labels: labels:
CONNECT: "localhost:8025" CONNECT: "localhost:8025"
cpu_shares: "${CPU_SHARES_LOW}"
nwc: nwc:
build: build:
context: ./docker/nwc context: ./docker/nwc
@ -522,7 +507,6 @@ services:
- '0' - '0'
- '--daily-limit' - '--daily-limit'
- '0' - '0'
cpu_shares: "${CPU_SHARES_LOW}"
lnbits: lnbits:
image: lnbits/lnbits:0.12.5 image: lnbits/lnbits:0.12.5
container_name: lnbits container_name: lnbits
@ -541,9 +525,6 @@ services:
- LND_GRPC_MACAROON=/app/.lnd/regtest/admin.macaroon - LND_GRPC_MACAROON=/app/.lnd/regtest/admin.macaroon
volumes: volumes:
- ./docker/lnd/stacker:/app/.lnd - ./docker/lnd/stacker:/app/.lnd
labels:
CONNECT: "localhost:${LNBITS_WEB_PORT}"
cpu_shares: "${CPU_SHARES_LOW}"
volumes: volumes:
db: db:
os: os:

81
docs/attach-lnbits.md Normal file
View File

@ -0,0 +1,81 @@
# attach lnbits
To test sending from an attached wallet, it's easiest to use [lnbits](https://lnbits.com/) hooked up to a [local lnd node](./local-lnd.md) in your regtest network.
This will attempt to walk you through setting up lnbits with docker and connecting it to your local lnd node.
🚨 this a dev guide. do not use this guide for real funds 🚨
From [this guide](https://docs.lnbits.org/guide/installation.html#option-3-docker):
## 1. pre-configuration
Create a directory for lnbits, get the sample environment file, and create a shared data directory for lnbits to use:
```bash
mkdir lnbits
cd lnbits
wget https://raw.githubusercontent.com/lnbits/lnbits/main/.env.example -O .env
mkdir data
```
## 2. configure
To configure lnbits to use a [local lnd node](./local-lnd.md) in your regtest network, go to [polar](https://lightningpolar.com/) and click on the LND node you want to use as a funding source. Then click on `Connect`.
In the `Connect` tab, click the `File paths` tab and copy the `TLS cert` and `Admin macaroon` files to the `data` directory you created earlier.
```bash
cp /path/to/tls.cert /path/to/admin.macaroon data/
```
Then, open the `.env` file you created and override the following values:
```bash
LNBITS_ADMIN_UI=true
LNBITS_BACKEND_WALLET_CLASS=LndWallet
LND_GRPC_ENDPOINT=host.docker.internal
LND_GRPC_PORT=${Port from the polar connect page}
LND_GRPC_CERT=data/tls.cert
LND_GRPC_MACAROON=data/admin.macaroon
```
## 2. Install and run lnbits
Pull the latest image:
```bash
docker pull lnbitsdocker/lnbits-legend
docker run --detach --publish 5001:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbitsdocker/lnbits-legend
```
Note: we make lnbits available on the host's port 5001 here (on Mac, 5000 is used by AirPlay), but you can change that to whatever you want.
## 3. Accessing the admin wallet
By enabling the [Admin UI](https://docs.lnbits.org/guide/admin_ui.html), lnbits creates a so called super_user. Get this super_user id by running:
```bash
cat data/.super_user
```
Open your browser and go to `http://localhost:5001/wallet?usr=${super_user id from above}`. LNBits will redirect you to a default wallet we will use called `LNBits wallet`.
## 4. Fund the wallet
To fund `LNBits wallet`, click the `+` next the wallet balance. Enter the number of sats you want to credit the wallet and hit enter.
## 5. Attach the wallet to stackernews
Open up your local stackernews, go to `http://localhost:3000/settings/wallets` and click on `attach` in the `lnbits` card.
In the form, fill in `lnbits url` with `http://localhost:5001`.
Back in lnbits click on `API Docs` in the right pane. Copy the Admin key and paste it into the `admin key` field in the form.
Click `attach` and you should be good to go.
## Debugging
- you can view lnbits logs with `docker logs lnbits` or in `data/logs/` in the `data` directory you created earlier
- with the [Admin UI](https://docs.lnbits.org/guide/admin_ui.html), you can modify LNBits in the GUI by clicking `Server` in left pane

View File

@ -15,7 +15,7 @@ export const ME = gql`
diagnostics diagnostics
noReferralLinks noReferralLinks
fiatCurrency fiatCurrency
satsFilter greeterMode
hideCowboyHat hideCowboyHat
hideFromTopUsers hideFromTopUsers
hideGithub hideGithub
@ -104,7 +104,7 @@ export const SETTINGS_FIELDS = gql`
nostrCrossposting nostrCrossposting
nostrRelays nostrRelays
wildWestMode wildWestMode
satsFilter greeterMode
nsfwMode nsfwMode
authMethods { authMethods {
lightning lightning

View File

@ -129,10 +129,6 @@ export const WALLET = gql`
rune rune
cert cert
} }
... on WalletLNbits {
url
invoiceKey
}
} }
} }
} }
@ -161,10 +157,6 @@ export const WALLET_BY_TYPE = gql`
rune rune
cert cert
} }
... on WalletLNbits {
url
invoiceKey
}
} }
} }
} }

View File

@ -6,7 +6,7 @@ import {
} from './constants' } from './constants'
import { SUPPORTED_CURRENCIES } from './currency' import { SUPPORTED_CURRENCIES } from './currency'
import { NOSTR_MAX_RELAY_NUM, NOSTR_PUBKEY_BECH32, NOSTR_PUBKEY_HEX } from './nostr' import { NOSTR_MAX_RELAY_NUM, NOSTR_PUBKEY_BECH32, NOSTR_PUBKEY_HEX } from './nostr'
import { msatsToSats, numWithUnits, abbrNum, ensureB64, B64_URL_REGEX, HEX_REGEX } from './format' import { msatsToSats, numWithUnits, abbrNum, ensureB64, B64_URL_REGEX } from './format'
import * as usersFragments from '@/fragments/users' import * as usersFragments from '@/fragments/users'
import * as subsFragments from '@/fragments/subs' import * as subsFragments from '@/fragments/subs'
import { isInvoicableMacaroon, isInvoiceMacaroon } from './macaroon' import { isInvoicableMacaroon, isInvoiceMacaroon } from './macaroon'
@ -41,14 +41,6 @@ export async function formikValidate (validate, data) {
} }
} }
export async function walletValidate (wallet, data) {
if (typeof wallet.fieldValidation === 'function') {
return await formikValidate(wallet.fieldValidation, data)
} else {
await ssValidate(wallet.fieldValidation, data)
}
}
addMethod(string, 'or', function (schemas, msg) { addMethod(string, 'or', function (schemas, msg) {
return this.test({ return this.test({
name: 'or', name: 'or',
@ -150,14 +142,6 @@ addMethod(string, 'wss', function (msg) {
}) })
}) })
addMethod(string, 'hex', function (msg) {
return this.test({
name: 'hex',
message: msg || 'invalid hex encoding',
test: (value) => !value || HEX_REGEX.test(value)
})
})
const titleValidator = string().required('required').trim().max( const titleValidator = string().required('required').trim().max(
MAX_TITLE_LENGTH, MAX_TITLE_LENGTH,
({ max, value }) => `-${Math.abs(max - value.length)} characters remaining` ({ max, value }) => `-${Math.abs(max - value.length)} characters remaining`
@ -595,7 +579,6 @@ export const settingsSchema = object().shape({
diagnostics: boolean(), diagnostics: boolean(),
noReferralLinks: boolean(), noReferralLinks: boolean(),
hideIsContributor: boolean(), hideIsContributor: boolean(),
satsFilter: intValidator.required('required').min(0, 'must be at least 0').max(1000, 'must be at most 1000'),
zapUndos: intValidator.nullable().min(0, 'must be greater or equal to 0') zapUndos: intValidator.nullable().min(0, 'must be greater or equal to 0')
// exclude from cyclic analysis. see https://github.com/jquense/yup/issues/720 // exclude from cyclic analysis. see https://github.com/jquense/yup/issues/720
}, [['tipRandomMax', 'tipRandomMin']]) }, [['tipRandomMax', 'tipRandomMin']])
@ -637,7 +620,7 @@ export const lnAddrSchema = ({ payerData, min, max, commentAllowed } = {}) =>
return accum return accum
}, {}))) }, {})))
export const lnbitsSchema = object().shape({ export const lnbitsSchema = object({
url: process.env.NODE_ENV === 'development' url: process.env.NODE_ENV === 'development'
? string() ? string()
.or([string().matches(/^(http:\/\/)?localhost:\d+$/), string().url()], 'invalid url') .or([string().matches(/^(http:\/\/)?localhost:\d+$/), string().url()], 'invalid url')
@ -659,25 +642,8 @@ export const lnbitsSchema = object().shape({
} }
return true return true
}), }),
adminKey: string().length(32).hex() adminKey: string().length(32).required('required')
.when(['invoiceKey'], ([invoiceKey], schema) => { })
if (!invoiceKey) return schema.required('required if invoice key not set')
return schema.test({
test: adminKey => adminKey !== invoiceKey,
message: 'admin key cannot be the same as invoice key'
})
}),
invoiceKey: string().length(32).hex()
.when(['adminKey'], ([adminKey], schema) => {
if (!adminKey) return schema.required('required if admin key not set')
return schema.test({
test: invoiceKey => adminKey !== invoiceKey,
message: 'invoice key cannot be the same as admin key'
})
})
// need to set order to avoid cyclic dependencies in Yup schema
// see https://github.com/jquense/yup/issues/176#issuecomment-367352042
}, ['adminKey', 'invoiceKey'])
export const nwcSchema = object({ export const nwcSchema = object({
nwcUrl: string() nwcUrl: string()

View File

@ -140,7 +140,7 @@ export default function Settings ({ ssrData }) {
hideTwitter: settings?.hideTwitter, hideTwitter: settings?.hideTwitter,
imgproxyOnly: settings?.imgproxyOnly, imgproxyOnly: settings?.imgproxyOnly,
wildWestMode: settings?.wildWestMode, wildWestMode: settings?.wildWestMode,
satsFilter: settings?.satsFilter, greeterMode: settings?.greeterMode,
nsfwMode: settings?.nsfwMode, nsfwMode: settings?.nsfwMode,
nostrPubkey: settings?.nostrPubkey ? bech32encode(settings.nostrPubkey) : '', nostrPubkey: settings?.nostrPubkey ? bech32encode(settings.nostrPubkey) : '',
nostrCrossposting: settings?.nostrCrossposting, nostrCrossposting: settings?.nostrCrossposting,
@ -152,11 +152,7 @@ export default function Settings ({ ssrData }) {
noReferralLinks: settings?.noReferralLinks noReferralLinks: settings?.noReferralLinks
}} }}
schema={settingsSchema} schema={settingsSchema}
onSubmit={async ({ onSubmit={async ({ tipDefault, tipRandom, tipRandomMin, tipRandomMax, withdrawMaxFeeDefault, zapUndos, zapUndosEnabled, nostrPubkey, nostrRelays, ...values }) => {
tipDefault, tipRandom, tipRandomMin, tipRandomMax, withdrawMaxFeeDefault,
zapUndos, zapUndosEnabled, nostrPubkey, nostrRelays, satsFilter,
...values
}) => {
if (nostrPubkey.length === 0) { if (nostrPubkey.length === 0) {
nostrPubkey = null nostrPubkey = null
} else { } else {
@ -176,7 +172,6 @@ export default function Settings ({ ssrData }) {
tipRandomMin: tipRandom ? Number(tipRandomMin) : null, tipRandomMin: tipRandom ? Number(tipRandomMin) : null,
tipRandomMax: tipRandom ? Number(tipRandomMax) : null, tipRandomMax: tipRandom ? Number(tipRandomMax) : null,
withdrawMaxFeeDefault: Number(withdrawMaxFeeDefault), withdrawMaxFeeDefault: Number(withdrawMaxFeeDefault),
satsFilter: Number(satsFilter),
zapUndos: zapUndosEnabled ? Number(zapUndos) : null, zapUndos: zapUndosEnabled ? Number(zapUndos) : null,
nostrPubkey, nostrPubkey,
nostrRelays: nostrRelaysFiltered, nostrRelays: nostrRelaysFiltered,
@ -472,27 +467,7 @@ export default function Settings ({ ssrData }) {
label={<>don't create referral links on copy</>} label={<>don't create referral links on copy</>}
name='noReferralLinks' name='noReferralLinks'
/> />
<h4>content</h4> <div className='form-label'>content</div>
<Input
label={
<div className='d-flex align-items-center'>filter by sats
<Info>
<ul className='fw-bold'>
<li>hide the post if the sum of these is less than your setting:</li>
<ul>
<li>posting cost</li>
<li>total sats from zaps</li>
<li>boost</li>
</ul>
<li>set to zero to be a greeter, with the tradeoff of seeing more spam</li>
</ul>
</Info>
</div>
}
name='satsFilter'
required
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
/>
<Checkbox <Checkbox
label={ label={
<div className='d-flex align-items-center'>wild west mode <div className='d-flex align-items-center'>wild west mode
@ -507,6 +482,21 @@ export default function Settings ({ ssrData }) {
name='wildWestMode' name='wildWestMode'
groupClassName='mb-0' groupClassName='mb-0'
/> />
<Checkbox
label={
<div className='d-flex align-items-center'>greeter mode
<Info>
<ul className='fw-bold'>
<li>see and screen free posts and comments</li>
<li>help onboard new stackers to SN and Lightning</li>
<li>you might be subject to more spam</li>
</ul>
</Info>
</div>
}
name='greeterMode'
groupClassName='mb-0'
/>
<Checkbox <Checkbox
label={ label={
<div className='d-flex align-items-center'>nsfw mode <div className='d-flex align-items-center'>nsfw mode

View File

@ -57,11 +57,15 @@ export default function WalletSettings () {
await wallet.save(values) await wallet.save(values)
if (values.enabled) wallet.enable()
else wallet.disable()
toaster.success('saved settings') toaster.success('saved settings')
router.push('/settings/wallets') router.push('/settings/wallets')
} catch (err) { } catch (err) {
console.error(err) console.error(err)
toaster.danger(err.message || err.toString?.()) const message = 'failed to attach: ' + err.message || err.toString?.()
toaster.danger(message)
} }
}} }}
> >
@ -102,12 +106,12 @@ export default function WalletSettings () {
function WalletFields ({ wallet: { config, fields, isConfigured } }) { function WalletFields ({ wallet: { config, fields, isConfigured } }) {
return fields return fields
.map(({ name, label, type, help, optional, editable, clientOnly, serverOnly, ...props }, i) => { .map(({ name, label, type, help, optional, editable, ...props }, i) => {
const rawProps = { const rawProps = {
...props, ...props,
name, name,
initialValue: config?.[name], initialValue: config?.[name],
readOnly: isConfigured && editable === false && !!config?.[name], readOnly: isConfigured && editable === false,
groupClassName: props.hidden ? 'd-none' : undefined, groupClassName: props.hidden ? 'd-none' : undefined,
label: label label: label
? ( ? (

View File

@ -1,24 +0,0 @@
-- AlterEnum
ALTER TYPE "WalletType" ADD VALUE 'LNBITS';
-- CreateTable
CREATE TABLE "WalletLNbits" (
"int" SERIAL NOT NULL,
"walletId" INTEGER NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"url" TEXT NOT NULL,
"invoiceKey" TEXT NOT NULL,
CONSTRAINT "WalletLNbits_pkey" PRIMARY KEY ("int")
);
-- CreateIndex
CREATE UNIQUE INDEX "WalletLNbits_walletId_key" ON "WalletLNbits"("walletId");
-- AddForeignKey
ALTER TABLE "WalletLNbits" ADD CONSTRAINT "WalletLNbits_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "Wallet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
CREATE TRIGGER wallet_lnbits_as_jsonb
AFTER INSERT OR UPDATE ON "WalletLNbits"
FOR EACH ROW EXECUTE PROCEDURE wallet_wallet_type_as_jsonb();

View File

@ -1,14 +0,0 @@
-- AlterTable
ALTER TABLE "Item" ADD COLUMN "cost" INTEGER NOT NULL DEFAULT 0;
-- use existing "ItemAct".act = FEE AND "Item"."userId" = "ItemAct"."userId" to calculate the cost for existing "Item"s
UPDATE "Item" SET "cost" = "ItemAct"."msats" / 1000
FROM "ItemAct"
WHERE "Item"."id" = "ItemAct"."itemId" AND "ItemAct"."act" = 'FEE' AND "Item"."userId" = "ItemAct"."userId";
ALTER TABLE "users" ADD COLUMN "satsFilter" INTEGER NOT NULL DEFAULT 10;
UPDATE "users" SET "satsFilter" = 0 WHERE "greeterMode";
-- CreateIndex
CREATE INDEX "Item_cost_idx" ON "Item"("cost");

View File

@ -1,4 +0,0 @@
-- fix missing 'bio' marker for bios
UPDATE "Item" SET bio = 't' WHERE id IN (
SELECT "bioId" FROM users WHERE "bioId" IS NOT NULL
);

View File

@ -54,7 +54,7 @@ model User {
upvoteTrust Float @default(0) upvoteTrust Float @default(0)
hideInvoiceDesc Boolean @default(false) hideInvoiceDesc Boolean @default(false)
wildWestMode Boolean @default(false) wildWestMode Boolean @default(false)
satsFilter Int @default(10) greeterMode Boolean @default(false)
nsfwMode Boolean @default(false) nsfwMode Boolean @default(false)
fiatCurrency String @default("USD") fiatCurrency String @default("USD")
withdrawMaxFeeDefault Int @default(10) withdrawMaxFeeDefault Int @default(10)
@ -66,7 +66,6 @@ model User {
hideWalletBalance Boolean @default(false) hideWalletBalance Boolean @default(false)
referrerId Int? referrerId Int?
nostrPubkey String? nostrPubkey String?
greeterMode Boolean @default(false)
nostrAuthPubkey String? @unique(map: "users.nostrAuthPubkey_unique") nostrAuthPubkey String? @unique(map: "users.nostrAuthPubkey_unique")
nostrCrossposting Boolean @default(false) nostrCrossposting Boolean @default(false)
slashtagId String? @unique(map: "users.slashtagId_unique") slashtagId String? @unique(map: "users.slashtagId_unique")
@ -168,7 +167,6 @@ enum WalletType {
LIGHTNING_ADDRESS LIGHTNING_ADDRESS
LND LND
CLN CLN
LNBITS
} }
model Wallet { model Wallet {
@ -192,7 +190,6 @@ model Wallet {
walletLightningAddress WalletLightningAddress? walletLightningAddress WalletLightningAddress?
walletLND WalletLND? walletLND WalletLND?
walletCLN WalletCLN? walletCLN WalletCLN?
walletLNbits WalletLNbits?
withdrawals Withdrawl[] withdrawals Withdrawl[]
@@index([userId]) @@index([userId])
@ -241,16 +238,6 @@ model WalletCLN {
cert String? cert String?
} }
model WalletLNbits {
int Int @id @default(autoincrement())
walletId Int @unique
wallet Wallet @relation(fields: [walletId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
url String
invoiceKey String
}
model Mute { model Mute {
muterId Int muterId Int
mutedId Int mutedId Int
@ -438,7 +425,6 @@ model Item {
lastZapAt DateTime? lastZapAt DateTime?
ncomments Int @default(0) ncomments Int @default(0)
msats BigInt @default(0) msats BigInt @default(0)
cost Int @default(0)
weightedDownVotes Float @default(0) weightedDownVotes Float @default(0)
bio Boolean @default(false) bio Boolean @default(false)
freebie Boolean @default(false) freebie Boolean @default(false)
@ -503,7 +489,6 @@ model Item {
@@index([weightedVotes], map: "Item.weightedVotes_index") @@index([weightedVotes], map: "Item.weightedVotes_index")
@@index([invoiceId]) @@index([invoiceId])
@@index([invoiceActionState]) @@index([invoiceActionState])
@@index([cost])
} }
// we use this to denormalize a user's aggregated interactions (zaps) with an item // we use this to denormalize a user's aggregated interactions (zaps) with an item

View File

@ -1,6 +1,6 @@
import { useCallback } from 'react' import { useCallback } from 'react'
import { useMe } from '@/components/me' import { useMe } from '@/components/me'
import useClientConfig from '@/components/use-local-state' import useLocalConfig from '@/components/use-local-state'
import { useWalletLogger } from '@/components/wallet-logger' import { useWalletLogger } from '@/components/wallet-logger'
import { SSR } from '@/lib/constants' import { SSR } from '@/lib/constants'
import { bolt11Tags } from '@/lib/bolt11' import { bolt11Tags } from '@/lib/bolt11'
@ -12,7 +12,6 @@ import { autowithdrawInitial } from '@/components/autowithdraw-shared'
import { useShowModal } from '@/components/modal' import { useShowModal } from '@/components/modal'
import { useToast } from '../components/toast' import { useToast } from '../components/toast'
import { generateResolverName } from '@/lib/wallet' import { generateResolverName } from '@/lib/wallet'
import { walletValidate } from '@/lib/validate'
export const Status = { export const Status = {
Initialized: 'Initialized', Initialized: 'Initialized',
@ -33,22 +32,6 @@ export function useWallet (name) {
const hasConfig = wallet?.fields.length > 0 const hasConfig = wallet?.fields.length > 0
const _isConfigured = isConfigured({ ...wallet, config }) const _isConfigured = isConfigured({ ...wallet, config })
const enablePayments = useCallback(() => {
enableWallet(name, me)
logger.ok('payments enabled')
}, [name, me, logger])
const disablePayments = useCallback(() => {
disableWallet(name, me)
logger.info('payments disabled')
}, [name, me, logger])
if (wallet) {
wallet.isConfigured = _isConfigured
wallet.enablePayments = enablePayments
wallet.disablePayments = disablePayments
}
const status = config?.enabled ? Status.Enabled : Status.Initialized const status = config?.enabled ? Status.Enabled : Status.Initialized
const enabled = status === Status.Enabled const enabled = status === Status.Enabled
const priority = config?.priority const priority = config?.priority
@ -66,40 +49,53 @@ export function useWallet (name) {
} }
}, [me, wallet, config, logger, status]) }, [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) => { const setPriority = useCallback(async (priority) => {
if (_isConfigured && priority !== config.priority) { if (_isConfigured && priority !== config.priority) {
try { try {
await saveConfig({ ...config, priority }, { logger }) await saveConfig({ ...config, priority })
} catch (err) { } catch (err) {
toaster.danger(`failed to change priority of ${wallet.name} wallet: ${err.message}`) toaster.danger(`failed to change priority of ${wallet.name} wallet: ${err.message}`)
} }
} }
}, [wallet, config, toaster]) }, [wallet, config, logger, toaster])
const save = useCallback(async (newConfig) => { const save = useCallback(async (newConfig) => {
// testConnectClient should log custom INFO and OK message
// testConnectClient is optional since validation might happen during save on server
// TODO: add timeout
let validConfig
try { try {
validConfig = await wallet.testConnectClient?.(newConfig, { me, logger }) // testConnectClient should log custom INFO and OK message
// testConnectClient is optional since validation might happen during save on server
// TODO: add timeout
const validConfig = await wallet.testConnectClient?.(newConfig, { me, logger })
await saveConfig(validConfig ?? newConfig)
logger.ok(_isConfigured ? 'wallet updated' : 'wallet attached')
} catch (err) { } catch (err) {
logger.error(err.message) const message = err.message || err.toString?.()
logger.error('failed to attach: ' + message)
throw err throw err
} }
await saveConfig(validConfig ?? newConfig, { logger }) }, [_isConfigured, saveConfig, me, logger])
}, [saveConfig, me, logger])
// delete is a reserved keyword // delete is a reserved keyword
const delete_ = useCallback(async () => { const delete_ = useCallback(async () => {
try { try {
await clearConfig({ logger }) await clearConfig()
logger.ok('wallet detached')
disable()
} catch (err) { } catch (err) {
const message = err.message || err.toString?.() const message = err.message || err.toString?.()
logger.error(message) logger.error(message)
throw err throw err
} }
}, [clearConfig, logger, disablePayments]) }, [clearConfig, logger, disable])
if (!wallet) return null if (!wallet) return null
@ -110,8 +106,11 @@ export function useWallet (name) {
save, save,
delete: delete_, delete: delete_,
deleteLogs, deleteLogs,
enable,
disable,
setPriority, setPriority,
hasConfig, hasConfig,
isConfigured: _isConfigured,
status, status,
enabled, enabled,
priority, priority,
@ -119,111 +118,34 @@ export function useWallet (name) {
} }
} }
function extractConfig (fields, config, client) {
return Object.entries(config).reduce((acc, [key, value]) => {
const field = fields.find(({ name }) => name === key)
// filter server config which isn't specified as wallet fields
if (client && (key.startsWith('autoWithdraw') || key === 'id')) return acc
// field might not exist because config.enabled doesn't map to a wallet field
if (!field || (client ? isClientField(field) : isServerField(field))) {
return {
...acc,
[key]: value
}
} else {
return acc
}
}, {})
}
export function isServerField (f) {
return f.serverOnly || !f.clientOnly
}
export function isClientField (f) {
return f.clientOnly || !f.serverOnly
}
function extractClientConfig (fields, config) {
return extractConfig(fields, config, true)
}
function extractServerConfig (fields, config) {
return extractConfig(fields, config, false)
}
function useConfig (wallet) { function useConfig (wallet) {
const me = useMe() const me = useMe()
const storageKey = getStorageKey(wallet?.name, me) const storageKey = getStorageKey(wallet?.name, me)
const [clientConfig, setClientConfig, clearClientConfig] = useClientConfig(storageKey) const [localConfig, setLocalConfig, clearLocalConfig] = useLocalConfig(storageKey)
const [serverConfig, setServerConfig, clearServerConfig] = useServerConfig(wallet) const [serverConfig, setServerConfig, clearServerConfig] = useServerConfig(wallet)
const hasClientConfig = !!wallet?.sendPayment const hasLocalConfig = !!wallet?.sendPayment
const hasServerConfig = !!wallet?.walletType const hasServerConfig = !!wallet?.walletType
let config = {} const config = {
if (hasClientConfig) config = clientConfig // only include config if it makes sense for this wallet
if (hasServerConfig) { // since server config always returns default values for autowithdraw settings
const { enabled } = config || {} // which might be confusing to have for wallets that don't support autowithdraw
config = { ...(hasLocalConfig ? localConfig : {}),
...config, ...(hasServerConfig ? serverConfig : {})
...serverConfig
}
// wallet is enabled if enabled is set in client or server config
config.enabled ||= enabled
} }
const saveConfig = useCallback(async (newConfig, { logger }) => { const saveConfig = useCallback(async (config) => {
// NOTE: if (hasLocalConfig) setLocalConfig(config)
// verifying the client/server configuration before saving it if (hasServerConfig) await setServerConfig(config)
// prevents unsetting just one configuration if both are set. }, [wallet])
// This means there is no way of unsetting just one configuration
// since 'detach' detaches both.
// Not optimal UX but the trade-off is saving invalid configurations
// and maybe it's not that big of an issue.
if (hasClientConfig) {
const newClientConfig = extractClientConfig(wallet.fields, newConfig)
let valid = true const clearConfig = useCallback(async () => {
try { if (hasLocalConfig) clearLocalConfig()
await walletValidate(wallet, newClientConfig)
} catch {
valid = false
}
if (valid) {
setClientConfig(newClientConfig)
logger.ok(wallet.isConfigured ? 'payment details updated' : 'wallet attached for payments')
if (newConfig.enabled) wallet.enablePayments()
else wallet.disablePayments()
}
}
if (hasServerConfig) {
const newServerConfig = extractServerConfig(wallet.fields, newConfig)
let valid = true
try {
await walletValidate(wallet, newServerConfig)
} catch {
valid = false
}
if (valid) await setServerConfig(newServerConfig)
}
}, [hasClientConfig, hasServerConfig, setClientConfig, setServerConfig, wallet])
const clearConfig = useCallback(async ({ logger }) => {
if (hasClientConfig) {
clearClientConfig()
wallet.disablePayments()
logger.ok('wallet detached for payments')
}
if (hasServerConfig) await clearServerConfig() if (hasServerConfig) await clearServerConfig()
}, [hasClientConfig, hasServerConfig, clearClientConfig, clearServerConfig, wallet]) }, [wallet])
return [config, saveConfig, clearConfig] return [config, saveConfig, clearConfig]
} }
@ -252,8 +174,6 @@ function useServerConfig (wallet) {
enabled: data?.walletByType?.enabled, enabled: data?.walletByType?.enabled,
...data?.walletByType?.wallet ...data?.walletByType?.wallet
} }
delete serverConfig.__typename
const autowithdrawSettings = autowithdrawInitial({ me }) const autowithdrawSettings = autowithdrawInitial({ me })
const config = { ...serverConfig, ...autowithdrawSettings } const config = { ...serverConfig, ...autowithdrawSettings }
@ -269,8 +189,8 @@ function useServerConfig (wallet) {
return await client.mutate({ return await client.mutate({
mutation, mutation,
variables: { variables: {
...config,
id: walletId, id: walletId,
...config,
settings: { settings: {
autoWithdrawThreshold: Number(autoWithdrawThreshold), autoWithdrawThreshold: Number(autoWithdrawThreshold),
autoWithdrawMaxFeePercent: Number(autoWithdrawMaxFeePercent), autoWithdrawMaxFeePercent: Number(autoWithdrawMaxFeePercent),
@ -286,9 +206,6 @@ function useServerConfig (wallet) {
}, [client, walletId]) }, [client, walletId])
const clearConfig = useCallback(async () => { const clearConfig = useCallback(async () => {
// only remove wallet if there is a wallet to remove
if (!walletId) return
try { try {
await client.mutate({ await client.mutate({
mutation: REMOVE_WALLET, mutation: REMOVE_WALLET,
@ -307,21 +224,17 @@ function generateMutation (wallet) {
const resolverName = generateResolverName(wallet.walletField) const resolverName = generateResolverName(wallet.walletField)
let headerArgs = '$id: ID, ' let headerArgs = '$id: ID, '
headerArgs += wallet.fields headerArgs += wallet.fields.map(f => {
.filter(isServerField) let arg = `$${f.name}: String`
.map(f => { if (!f.optional) {
let arg = `$${f.name}: String` arg += '!'
if (!f.optional) { }
arg += '!' return arg
} }).join(', ')
return arg
}).join(', ')
headerArgs += ', $settings: AutowithdrawSettings!' headerArgs += ', $settings: AutowithdrawSettings!'
let inputArgs = 'id: $id, ' let inputArgs = 'id: $id, '
inputArgs += wallet.fields inputArgs += wallet.fields.map(f => `${f.name}: $${f.name}`).join(', ')
.filter(isServerField)
.map(f => `${f.name}: $${f.name}`).join(', ')
inputArgs += ', settings: $settings' inputArgs += ', settings: $settings'
return gql`mutation ${resolverName}(${headerArgs}) { return gql`mutation ${resolverName}(${headerArgs}) {

View File

@ -1,27 +0,0 @@
For testing LNbits, you need to create a LNbits account first via the web interface.
By default, you can access it at `localhost:5001` (see `LNBITS_WEB_PORT` in .env.development).
After you created a wallet, you should find the invoice and admin key under `Node URL, API keys and API docs`.
> [!IMPORTANT]
>
> Since your browser is running on your host machine but the server is running inside a docker container, the server will not be able to reach LNbits with `localhost:5001` to create invoices. This makes it hard to test send+receive at the same time.
>
> For now, you need to patch the `_createInvoice` function in wallets/lnbits/server.js to always use `lnbits:5000` as the URL:
>
> ```diff
> diff --git a/wallets/lnbits/server.js b/wallets/lnbits/server.js
> index 39949775..e3605c45 100644
> --- a/wallets/lnbits/server.js
> +++ b/wallets/lnbits/server.js
> @@ -11,6 +11,7 @@ async function _createInvoice ({ url, invoiceKey, amount, expiry }, { me }) {
> const memo = me.hideInvoiceDesc ? undefined : 'autowithdraw to LNbits from SN'
> const body = JSON.stringify({ amount, unit: 'sat', expiry, memo, out: false })
>
> + url = 'http://lnbits:5000'
> const res = await fetch(url + path, { method: 'POST', headers, body })
> if (!res.ok) {
> const errBody = await res.json()
> ```
>

View File

@ -1,10 +1,10 @@
export * from 'wallets/lnbits' export * from 'wallets/lnbits'
export async function testConnectClient ({ url, adminKey, invoiceKey }, { logger }) { export async function testConnectClient ({ url, adminKey }, { logger }) {
logger.info('trying to fetch wallet') logger.info('trying to fetch wallet')
url = url.replace(/\/+$/, '') url = url.replace(/\/+$/, '')
await getWallet({ url, adminKey, invoiceKey }) await getWallet({ url, adminKey })
logger.ok('wallet found') logger.ok('wallet found')
} }
@ -23,13 +23,13 @@ export async function sendPayment (bolt11, { url, adminKey }) {
return { preimage } return { preimage }
} }
async function getWallet ({ url, adminKey, invoiceKey }) { async function getWallet ({ url, adminKey }) {
const path = '/api/v1/wallet' const path = '/api/v1/wallet'
const headers = new Headers() const headers = new Headers()
headers.append('Accept', 'application/json') headers.append('Accept', 'application/json')
headers.append('Content-Type', 'application/json') headers.append('Content-Type', 'application/json')
headers.append('X-Api-Key', adminKey || invoiceKey) headers.append('X-Api-Key', adminKey)
const res = await fetch(url + path, { method: 'GET', headers }) const res = await fetch(url + path, { method: 'GET', headers })
if (!res.ok) { if (!res.ok) {

View File

@ -8,32 +8,17 @@ export const fields = [
label: 'lnbits url', label: 'lnbits url',
type: 'text' type: 'text'
}, },
{
name: 'invoiceKey',
label: 'invoice key',
type: 'password',
optional: 'for receiving',
serverOnly: true,
editable: false
},
{ {
name: 'adminKey', name: 'adminKey',
label: 'admin key', label: 'admin key',
type: 'password', type: 'password'
optional: 'for sending',
clientOnly: true,
editable: false
} }
] ]
export const card = { export const card = {
title: 'LNbits', title: 'LNbits',
subtitle: 'use [LNbits](https://lnbits.com/) for payments', subtitle: 'use [LNbits](https://lnbits.com/) for payments',
badges: ['send & receive'] badges: ['send only']
} }
export const fieldValidation = lnbitsSchema export const fieldValidation = lnbitsSchema
export const walletType = 'LNBITS'
export const walletField = 'walletLNbits'

View File

@ -1,30 +0,0 @@
export * from 'wallets/lnbits'
async function _createInvoice ({ url, invoiceKey, amount, expiry }, { me }) {
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', invoiceKey)
const memo = me.hideInvoiceDesc ? undefined : 'autowithdraw to LNbits from SN'
const body = JSON.stringify({ amount, unit: 'sat', expiry, memo, out: false })
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.payment_request
}
export async function testConnectServer ({ url, invoiceKey }, { me }) {
return await _createInvoice({ url, invoiceKey, amount: 1, expiry: 1 }, { me })
}
export async function createInvoice ({ amount, maxFee }, { url, invoiceKey }, { me }) {
return await _createInvoice({ url, invoiceKey, amount, expiry: 360 }, { me })
}

View File

@ -1,6 +1,5 @@
import * as lnd from 'wallets/lnd/server' import * as lnd from 'wallets/lnd/server'
import * as cln from 'wallets/cln/server' import * as cln from 'wallets/cln/server'
import * as lnAddr from 'wallets/lightning-address/server' import * as lnAddr from 'wallets/lightning-address/server'
import * as lnbits from 'wallets/lnbits/server'
export default [lnd, cln, lnAddr, lnbits] export default [lnd, cln, lnAddr]