Compare commits

..

9 Commits

Author SHA1 Message Date
ekzyis
454ad26bd7
Add migration to fix missing item bio marker (#1295) 2024-08-12 19:19:18 -05:00
k00b
ce7d2b888d add back greeterMode for backwards compat 2024-08-12 17:49:01 -05:00
ekzyis
ae73b0c19f
Support receiving via LNbits (#1278)
* Support receiving with LNbits

* Remove hardcoded LNbits url on server

* Fix saveConfig ignoring save errors

* saveConfig was meant to only ignore validation errors, not save errors
* on server save errors, we redirected as if save was successful
* this is now fixed with a promise chain
* logging payments vs receivals was also moved to correct place

* Fix enabled falsely disabled on SSR

If a wallet was configured for payments but not for receivals and you refreshed the configuration form, enabled was disabled even though payments were enabled.

This was the case since we don't know during SSR if it's enabled since this information is stored on the client.

* Fix missing 'receivals disabled' log message

* Move 'wallet detached for payments' log message

* Fix stale walletId during detach

If page was reloaded, walletId in clearConfig was stale since callback dependency was missing.

* Add missing callback dependencies for saveConfig

* Verify that invoiceKey != adminKey

* Verify LNbits keys are hex-encoded

* Fix local config polluted with server data

* Fix creation of duplicate wallets

* Remove unused dependency

* Fix missing error message in logs

* Fix setPriority

* Rename: localConfig -> clientConfig

* Add description to LNbits autowithdrawals

* Rename: receivals -> receives

* Use try/catch instead of promise chain in saveConfig

* add connect label to lnbits for no url found for lnbits

* Fix adminKey not saved

* Remove hardcoded LNbits url on server again

* Add LNbits ATTACH.md

* Delete old docs to attach LNbits with polar

* Add missing callback dependencies

* Set editable: false

* Only set readOnly if field is configured

---------

Co-authored-by: keyan <keyan.kousha+huumn@gmail.com>
Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2024-08-12 17:23:39 -05:00
Keyan
68758b3443
Update awards.csv 2024-08-12 16:58:49 -05:00
Keyan
c5f043c625
replace greeter mode with investment filter (#1291)
* replace greeter mode with investment filter

* change name to satsFilter

* drop freebie column

---------

Co-authored-by: k00b <k00b@stacker.news>
2024-08-11 18:47:03 -05:00
Keyan
e897a2d1dc
Update awards.csv 2024-08-11 16:31:44 -05:00
Anis Khalfallah
ed6ef2f82f
fix: constrain less important services in docker compose (#1289)
Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2024-08-11 16:29:46 -05:00
ekzyis
bcae5e6d2e
Fix callback set to NextJS data URL (#1292) 2024-08-10 14:38:35 -05:00
Keyan
ef229b378e
Update awards.csv 2024-08-10 11:17:22 -05:00
28 changed files with 448 additions and 238 deletions

View File

@ -160,3 +160,8 @@ 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 { satsToMsats } from '@/lib/format' import { msatsToSats, satsToMsats } from '@/lib/format'
export const anonable = true export const anonable = true
export const supportsPessimism = true export const supportsPessimism = true
@ -51,8 +51,7 @@ 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
}) })
} else { data.cost = msatsToSats(cost - boostMsats)
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".freebie IS FALSE) DESC, "Item".id DESC` "Item".msats DESC, ("Item".cost > 0) 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".freebie IS FALSE) DESC, "Item".id DESC` return `ORDER BY ${orderByNumerator(models, 0)} DESC NULLS LAST, "Item".msats DESC, ("Item".cost > 0) 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".freebie IS FALSE) 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".cost > 0) DESC, "Item".id DESC`
} }
} }
} }
@ -225,22 +225,16 @@ 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 freebieClauses = ['NOT "Item".freebie', '"Item"."weightedVotes" - "Item"."weightedDownVotes" > 0'] let investmentClause = '("Item".cost + "Item".boost + ("Item".msats / 1000)) >= 10'
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
if (user.wildWestMode) {
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 investmentClause = `(("Item".cost + "Item".boost + ("Item".msats / 1000)) >= ${user.satsFilter} OR "Item"."userId" = ${me.id})`
freebieClauses.push(`"Item"."userId" = ${me.id}`)
if (user.wildWestMode) {
return investmentClause
}
} }
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
@ -250,7 +244,7 @@ export async function filterClause (me, models, type) {
} }
const outlawClause = '(' + outlawClauses.join(' OR ') + ')' const outlawClause = '(' + outlawClauses.join(' OR ') + ')'
return [freebieClause, outlawClause] return [investmentClause, outlawClause]
} }
function typeClause (type) { function typeClause (type) {
@ -268,7 +262,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".freebie' return '"Item".cost = 0'
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':
@ -470,10 +464,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".freebie IS FALSE) 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".cost > 0) 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".freebie IS FALSE) 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".cost > 0) DESC, "Item".id DESC`
}, decodedCursor.offset, limit, ...subArr) }, decodedCursor.offset, limit, ...subArr)
} }
@ -1054,6 +1048,9 @@ 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') {
@ -1255,7 +1252,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.freebie) { if (old.cost === 0) {
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, formikValidate } from '@/lib/validate' import { amountSchema, ssValidate, withdrawlSchema, lnAddrSchema, walletValidate } 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,20 +19,14 @@ 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 { fieldValidation, walletType, walletField, testConnectServer } = w const resolverName = generateResolverName(w.walletField)
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({
...validateArgs, wallet: { field: w.walletField, type: w.walletType },
wallet: { field: walletField, type: walletType }, testConnectServer: (data) => w.testConnectServer(data, { me, models })
testConnectServer: (data) => testConnectServer(data, { me, models })
}, { settings, data }, { me, models }) }, { settings, data }, { me, models })
} }
} }
@ -353,7 +347,7 @@ const resolvers = {
}, },
WalletDetails: { WalletDetails: {
__resolveType (wallet) { __resolveType (wallet) {
return wallet.address ? 'WalletLNAddr' : wallet.macaroon ? 'WalletLND' : 'WalletCLN' return wallet.address ? 'WalletLNAddr' : wallet.macaroon ? 'WalletLND' : wallet.rune ? 'WalletCLN' : 'WalletLNbits'
} }
}, },
Mutation: { Mutation: {
@ -462,7 +456,8 @@ 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: 'SUCCESS', message: 'wallet detached' } }) 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 for receives' } })
]) ])
return true return true
@ -557,26 +552,20 @@ export const addWalletLog = async ({ wallet, level, message }, { me, models }) =
} }
async function upsertWallet ( async function upsertWallet (
{ schema, formikValidate: validate, wallet, testConnectServer }, { settings, data }, { me, models }) { { 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 = err.message || err.toString?.() const message = 'failed to create test invoice: ' + (err.message || err.toString?.())
await addWalletLog({ wallet, level: 'ERROR', message: 'failed to attach: ' + message }, { me, models }) await addWalletLog({ wallet, level: 'ERROR', 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' } })
} }
} }
@ -632,7 +621,7 @@ async function upsertWallet (
userId: me.id, userId: me.id,
wallet: wallet.type, wallet: wallet.type,
level: 'SUCCESS', level: 'SUCCESS',
message: id ? 'wallet updated' : 'wallet attached' message: id ? 'receive details updated' : 'wallet attached for receives'
} }
}), }),
models.walletLog.create({ models.walletLog.create({
@ -640,7 +629,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 ? 'wallet enabled' : 'wallet disabled' message: enabled ? 'receives enabled' : 'receives disabled'
} }
}) })
) )

View File

@ -142,7 +142,11 @@ 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) {
const callback = process.env.NEXT_PUBLIC_URL + req.url let 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,6 +71,7 @@ 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!
@ -140,6 +141,7 @@ 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,19 +2,22 @@ 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.map(f => { args += w.fields
let arg = `${f.name}: String` .filter(isServerField)
if (!f.optional) { .map(f => {
arg += '!' let arg = `${f.name}: String`
} if (!f.optional) {
return arg 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`
@ -74,7 +77,12 @@ const typeDefs = `
cert: String cert: String
} }
union WalletDetails = WalletLNAddr | WalletLND | WalletCLN type WalletLNbits {
url: String!
invoiceKey: String!
}
union WalletDetails = WalletLNAddr | WalletLND | WalletCLN | WalletLNbits
input AutowithdrawSettings { input AutowithdrawSettings {
autoWithdrawThreshold: Int! autoWithdrawThreshold: Int!

View File

@ -118,4 +118,5 @@ 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,???,??? 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

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
122 aniskhalfallah pr #1289 easy 100k aniskhalfallah@blink.sv 2024-08-12

View File

@ -25,10 +25,15 @@ 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={!wallet.isConfigured} disabled={mounted && !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?.wildWestMode && !me?.privates?.greeterMode && !item.mine && item.freebie && !item.freedFreebie const isHiddenFreebie = me?.privates?.satsFilter !== 0 && !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,9 +243,6 @@ 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,6 +35,7 @@ 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
@ -58,6 +59,7 @@ 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:
@ -79,6 +81,7 @@ 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:
@ -97,6 +100,7 @@ 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
@ -113,6 +117,7 @@ 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
@ -138,6 +143,7 @@ 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
@ -177,6 +183,7 @@ 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
@ -198,6 +205,7 @@ 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
@ -254,6 +262,7 @@ 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
@ -311,6 +320,7 @@ 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
@ -370,6 +380,7 @@ 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:
@ -404,6 +415,7 @@ 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
@ -446,6 +458,7 @@ 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
@ -460,6 +473,7 @@ 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
@ -476,6 +490,7 @@ 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
@ -507,6 +522,7 @@ 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
@ -525,6 +541,9 @@ 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:

View File

@ -1,81 +0,0 @@
# 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
greeterMode satsFilter
hideCowboyHat hideCowboyHat
hideFromTopUsers hideFromTopUsers
hideGithub hideGithub
@ -104,7 +104,7 @@ export const SETTINGS_FIELDS = gql`
nostrCrossposting nostrCrossposting
nostrRelays nostrRelays
wildWestMode wildWestMode
greeterMode satsFilter
nsfwMode nsfwMode
authMethods { authMethods {
lightning lightning

View File

@ -129,6 +129,10 @@ export const WALLET = gql`
rune rune
cert cert
} }
... on WalletLNbits {
url
invoiceKey
}
} }
} }
} }
@ -157,6 +161,10 @@ 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 } from './format' import { msatsToSats, numWithUnits, abbrNum, ensureB64, B64_URL_REGEX, HEX_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,6 +41,14 @@ 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',
@ -142,6 +150,14 @@ 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`
@ -579,6 +595,7 @@ 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']])
@ -620,7 +637,7 @@ export const lnAddrSchema = ({ payerData, min, max, commentAllowed } = {}) =>
return accum return accum
}, {}))) }, {})))
export const lnbitsSchema = object({ export const lnbitsSchema = object().shape({
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')
@ -642,8 +659,25 @@ export const lnbitsSchema = object({
} }
return true return true
}), }),
adminKey: string().length(32).required('required') adminKey: string().length(32).hex()
}) .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,
greeterMode: settings?.greeterMode, satsFilter: settings?.satsFilter,
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,7 +152,11 @@ export default function Settings ({ ssrData }) {
noReferralLinks: settings?.noReferralLinks noReferralLinks: settings?.noReferralLinks
}} }}
schema={settingsSchema} schema={settingsSchema}
onSubmit={async ({ tipDefault, tipRandom, tipRandomMin, tipRandomMax, withdrawMaxFeeDefault, zapUndos, zapUndosEnabled, nostrPubkey, nostrRelays, ...values }) => { onSubmit={async ({
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 {
@ -172,6 +176,7 @@ 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,
@ -467,7 +472,27 @@ 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'
/> />
<div className='form-label'>content</div> <h4>content</h4>
<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
@ -482,21 +507,6 @@ 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,15 +57,11 @@ 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)
const message = 'failed to attach: ' + err.message || err.toString?.() toaster.danger(err.message || err.toString?.())
toaster.danger(message)
} }
}} }}
> >
@ -106,12 +102,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, ...props }, i) => { .map(({ name, label, type, help, optional, editable, clientOnly, serverOnly, ...props }, i) => {
const rawProps = { const rawProps = {
...props, ...props,
name, name,
initialValue: config?.[name], initialValue: config?.[name],
readOnly: isConfigured && editable === false, readOnly: isConfigured && editable === false && !!config?.[name],
groupClassName: props.hidden ? 'd-none' : undefined, groupClassName: props.hidden ? 'd-none' : undefined,
label: label label: label
? ( ? (

View File

@ -0,0 +1,24 @@
-- 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

@ -0,0 +1,14 @@
-- 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

@ -0,0 +1,4 @@
-- 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)
greeterMode Boolean @default(false) satsFilter Int @default(10)
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,6 +66,7 @@ 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")
@ -167,6 +168,7 @@ enum WalletType {
LIGHTNING_ADDRESS LIGHTNING_ADDRESS
LND LND
CLN CLN
LNBITS
} }
model Wallet { model Wallet {
@ -190,6 +192,7 @@ model Wallet {
walletLightningAddress WalletLightningAddress? walletLightningAddress WalletLightningAddress?
walletLND WalletLND? walletLND WalletLND?
walletCLN WalletCLN? walletCLN WalletCLN?
walletLNbits WalletLNbits?
withdrawals Withdrawl[] withdrawals Withdrawl[]
@@index([userId]) @@index([userId])
@ -238,6 +241,16 @@ 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
@ -425,6 +438,7 @@ 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)
@ -489,6 +503,7 @@ 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 useLocalConfig from '@/components/use-local-state' import useClientConfig 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,6 +12,7 @@ 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',
@ -32,6 +33,22 @@ 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
@ -49,53 +66,40 @@ 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 }) await saveConfig({ ...config, priority }, { logger })
} 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, logger, toaster]) }, [wallet, config, 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 {
// testConnectClient should log custom INFO and OK message validConfig = await wallet.testConnectClient?.(newConfig, { me, logger })
// 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) {
const message = err.message || err.toString?.() logger.error(err.message)
logger.error('failed to attach: ' + message)
throw err throw err
} }
}, [_isConfigured, saveConfig, me, logger]) await saveConfig(validConfig ?? newConfig, { 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() await clearConfig({ logger })
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, disable]) }, [clearConfig, logger, disablePayments])
if (!wallet) return null if (!wallet) return null
@ -106,11 +110,8 @@ 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,
@ -118,34 +119,111 @@ 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 [localConfig, setLocalConfig, clearLocalConfig] = useLocalConfig(storageKey) const [clientConfig, setClientConfig, clearClientConfig] = useClientConfig(storageKey)
const [serverConfig, setServerConfig, clearServerConfig] = useServerConfig(wallet) const [serverConfig, setServerConfig, clearServerConfig] = useServerConfig(wallet)
const hasLocalConfig = !!wallet?.sendPayment const hasClientConfig = !!wallet?.sendPayment
const hasServerConfig = !!wallet?.walletType const hasServerConfig = !!wallet?.walletType
const config = { let config = {}
// only include config if it makes sense for this wallet if (hasClientConfig) config = clientConfig
// since server config always returns default values for autowithdraw settings if (hasServerConfig) {
// which might be confusing to have for wallets that don't support autowithdraw const { enabled } = config || {}
...(hasLocalConfig ? localConfig : {}), config = {
...(hasServerConfig ? serverConfig : {}) ...config,
...serverConfig
}
// wallet is enabled if enabled is set in client or server config
config.enabled ||= enabled
} }
const saveConfig = useCallback(async (config) => { const saveConfig = useCallback(async (newConfig, { logger }) => {
if (hasLocalConfig) setLocalConfig(config) // NOTE:
if (hasServerConfig) await setServerConfig(config) // verifying the client/server configuration before saving it
}, [wallet]) // prevents unsetting just one configuration if both are set.
// 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)
const clearConfig = useCallback(async () => { let valid = true
if (hasLocalConfig) clearLocalConfig() try {
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()
}, [wallet]) }, [hasClientConfig, hasServerConfig, clearClientConfig, clearServerConfig, wallet])
return [config, saveConfig, clearConfig] return [config, saveConfig, clearConfig]
} }
@ -174,6 +252,8 @@ 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 }
@ -189,8 +269,8 @@ function useServerConfig (wallet) {
return await client.mutate({ return await client.mutate({
mutation, mutation,
variables: { variables: {
id: walletId,
...config, ...config,
id: walletId,
settings: { settings: {
autoWithdrawThreshold: Number(autoWithdrawThreshold), autoWithdrawThreshold: Number(autoWithdrawThreshold),
autoWithdrawMaxFeePercent: Number(autoWithdrawMaxFeePercent), autoWithdrawMaxFeePercent: Number(autoWithdrawMaxFeePercent),
@ -206,6 +286,9 @@ 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,
@ -224,17 +307,21 @@ function generateMutation (wallet) {
const resolverName = generateResolverName(wallet.walletField) const resolverName = generateResolverName(wallet.walletField)
let headerArgs = '$id: ID, ' let headerArgs = '$id: ID, '
headerArgs += wallet.fields.map(f => { headerArgs += wallet.fields
let arg = `$${f.name}: String` .filter(isServerField)
if (!f.optional) { .map(f => {
arg += '!' let arg = `$${f.name}: String`
} if (!f.optional) {
return arg arg += '!'
}).join(', ') }
return arg
}).join(', ')
headerArgs += ', $settings: AutowithdrawSettings!' headerArgs += ', $settings: AutowithdrawSettings!'
let inputArgs = 'id: $id, ' let inputArgs = 'id: $id, '
inputArgs += wallet.fields.map(f => `${f.name}: $${f.name}`).join(', ') inputArgs += wallet.fields
.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}) {

27
wallets/lnbits/ATTACH.md Normal file
View File

@ -0,0 +1,27 @@
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 }, { logger }) { export async function testConnectClient ({ url, adminKey, invoiceKey }, { logger }) {
logger.info('trying to fetch wallet') logger.info('trying to fetch wallet')
url = url.replace(/\/+$/, '') url = url.replace(/\/+$/, '')
await getWallet({ url, adminKey }) await getWallet({ url, adminKey, invoiceKey })
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 }) { async function getWallet ({ url, adminKey, invoiceKey }) {
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) headers.append('X-Api-Key', adminKey || invoiceKey)
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,17 +8,32 @@ 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 only'] badges: ['send & receive']
} }
export const fieldValidation = lnbitsSchema export const fieldValidation = lnbitsSchema
export const walletType = 'LNBITS'
export const walletField = 'walletLNbits'

30
wallets/lnbits/server.js Normal file
View File

@ -0,0 +1,30 @@
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,5 +1,6 @@
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] export default [lnd, cln, lnAddr, lnbits]