Compare commits
9 Commits
3863edb871
...
454ad26bd7
Author | SHA1 | Date | |
---|---|---|---|
|
454ad26bd7 | ||
|
ce7d2b888d | ||
|
ae73b0c19f | ||
|
68758b3443 | ||
|
c5f043c625 | ||
|
e897a2d1dc | ||
|
ed6ef2f82f | ||
|
bcae5e6d2e | ||
|
ef229b378e |
@ -159,4 +159,9 @@ SKIP_SSL_CERT_DOWNLOAD=1
|
||||
TOR_PROXY=http://127.0.0.1:7050/
|
||||
|
||||
# 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
|
@ -1,7 +1,7 @@
|
||||
import { ANON_ITEM_SPAM_INTERVAL, ITEM_SPAM_INTERVAL, USER_ID } from '@/lib/constants'
|
||||
import { notifyItemMention, notifyItemParents, notifyMention, notifyTerritorySubscribers, notifyUserSubscribers } from '@/lib/webPush'
|
||||
import { getItemMentions, getMentions, performBotBehavior } from './lib/item'
|
||||
import { satsToMsats } from '@/lib/format'
|
||||
import { msatsToSats, satsToMsats } from '@/lib/format'
|
||||
|
||||
export const anonable = true
|
||||
export const supportsPessimism = true
|
||||
@ -51,8 +51,7 @@ export async function perform (args, context) {
|
||||
itemActs.push({
|
||||
msats: cost - boostMsats, act: 'FEE', userId: data.userId, ...invoiceData
|
||||
})
|
||||
} else {
|
||||
data.freebie = true
|
||||
data.cost = msatsToSats(cost - boostMsats)
|
||||
}
|
||||
|
||||
const mentions = await getMentions(args, context)
|
||||
|
@ -30,12 +30,12 @@ function commentsOrderByClause (me, models, sort) {
|
||||
return `ORDER BY COALESCE(
|
||||
personal_hot_score,
|
||||
${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 {
|
||||
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 {
|
||||
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
|
||||
// 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) {
|
||||
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
|
||||
freebieClauses.push(`"Item"."userId" = ${me.id}`)
|
||||
investmentClause = `(("Item".cost + "Item".boost + ("Item".msats / 1000)) >= ${user.satsFilter} OR "Item"."userId" = ${me.id})`
|
||||
|
||||
if (user.wildWestMode) {
|
||||
return investmentClause
|
||||
}
|
||||
}
|
||||
const freebieClause = '(' + freebieClauses.join(' OR ') + ')'
|
||||
|
||||
// handle outlawed
|
||||
// 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 ') + ')'
|
||||
|
||||
return [freebieClause, outlawClause]
|
||||
return [investmentClause, outlawClause]
|
||||
}
|
||||
|
||||
function typeClause (type) {
|
||||
@ -268,7 +262,7 @@ function typeClause (type) {
|
||||
case 'comments':
|
||||
return '"Item"."parentId" IS NOT NULL'
|
||||
case 'freebies':
|
||||
return '"Item".freebie'
|
||||
return '"Item".cost = 0'
|
||||
case 'outlawed':
|
||||
return `"Item"."weightedVotes" - "Item"."weightedDownVotes" <= -${ITEM_FILTER_THRESHOLD} OR "Item".outlawed`
|
||||
case 'borderland':
|
||||
@ -470,10 +464,10 @@ export default {
|
||||
'"Item".bio = false',
|
||||
activeOrMine(me),
|
||||
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
|
||||
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)
|
||||
}
|
||||
|
||||
@ -1054,6 +1048,9 @@ export default {
|
||||
freedFreebie: async (item) => {
|
||||
return item.weightedVotes - item.weightedDownVotes > 0
|
||||
},
|
||||
freebie: async (item) => {
|
||||
return item.cost === 0
|
||||
},
|
||||
meSats: async (item, args, { me, models }) => {
|
||||
if (!me) return 0
|
||||
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
|
||||
if (differentSub) {
|
||||
const sub = await models.sub.findUnique({ where: { name: subName } })
|
||||
if (old.freebie) {
|
||||
if (old.cost === 0) {
|
||||
if (!sub.allowFreebies) {
|
||||
throw new GraphQLError(`~${subName} does not allow freebies`, { extensions: { code: 'BAD_INPUT' } })
|
||||
}
|
||||
|
@ -5,7 +5,7 @@ import serialize from './serial'
|
||||
import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
|
||||
import { SELECT, itemQueryWithMeta } from './item'
|
||||
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 { datePivot } from '@/lib/time'
|
||||
import assertGofacYourself from './ofac'
|
||||
@ -19,20 +19,14 @@ import { lnAddrOptions } from '@/lib/lnurl'
|
||||
function injectResolvers (resolvers) {
|
||||
console.group('injected GraphQL resolvers:')
|
||||
for (const w of walletDefs) {
|
||||
const { fieldValidation, walletType, walletField, testConnectServer } = w
|
||||
const resolverName = generateResolverName(walletField)
|
||||
const resolverName = generateResolverName(w.walletField)
|
||||
console.log(resolverName)
|
||||
|
||||
// check if wallet uses the form-level validation built into Formik or a Yup schema
|
||||
const validateArgs = typeof fieldValidation === 'function'
|
||||
? { formikValidate: fieldValidation }
|
||||
: { schema: fieldValidation }
|
||||
|
||||
resolvers.Mutation[resolverName] = async (parent, { settings, ...data }, { me, models }) => {
|
||||
await walletValidate(w, { ...data, ...settings })
|
||||
return await upsertWallet({
|
||||
...validateArgs,
|
||||
wallet: { field: walletField, type: walletType },
|
||||
testConnectServer: (data) => testConnectServer(data, { me, models })
|
||||
wallet: { field: w.walletField, type: w.walletType },
|
||||
testConnectServer: (data) => w.testConnectServer(data, { me, models })
|
||||
}, { settings, data }, { me, models })
|
||||
}
|
||||
}
|
||||
@ -353,7 +347,7 @@ const resolvers = {
|
||||
},
|
||||
WalletDetails: {
|
||||
__resolveType (wallet) {
|
||||
return wallet.address ? 'WalletLNAddr' : wallet.macaroon ? 'WalletLND' : 'WalletCLN'
|
||||
return wallet.address ? 'WalletLNAddr' : wallet.macaroon ? 'WalletLND' : wallet.rune ? 'WalletCLN' : 'WalletLNbits'
|
||||
}
|
||||
},
|
||||
Mutation: {
|
||||
@ -462,7 +456,8 @@ const resolvers = {
|
||||
|
||||
await models.$transaction([
|
||||
models.wallet.delete({ where: { userId: me.id, id: Number(id) } }),
|
||||
models.walletLog.create({ data: { userId: me.id, wallet: wallet.type, level: 'SUCCESS', message: 'wallet 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
|
||||
@ -557,26 +552,20 @@ export const addWalletLog = async ({ wallet, level, message }, { me, models }) =
|
||||
}
|
||||
|
||||
async function upsertWallet (
|
||||
{ schema, formikValidate: validate, wallet, testConnectServer }, { settings, data }, { me, models }) {
|
||||
{ wallet, testConnectServer }, { settings, data }, { me, models }) {
|
||||
if (!me) {
|
||||
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
|
||||
}
|
||||
assertApiKeyNotPermitted({ me })
|
||||
|
||||
if (schema) {
|
||||
await ssValidate(schema, { ...data, ...settings }, { me, models })
|
||||
}
|
||||
if (validate) {
|
||||
await formikValidate(validate, { ...data, ...settings })
|
||||
}
|
||||
|
||||
if (testConnectServer) {
|
||||
try {
|
||||
await testConnectServer(data)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
const message = err.message || err.toString?.()
|
||||
await addWalletLog({ wallet, level: 'ERROR', message: 'failed to attach: ' + message }, { me, models })
|
||||
const message = 'failed to create test invoice: ' + (err.message || err.toString?.())
|
||||
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' } })
|
||||
}
|
||||
}
|
||||
@ -632,7 +621,7 @@ async function upsertWallet (
|
||||
userId: me.id,
|
||||
wallet: wallet.type,
|
||||
level: 'SUCCESS',
|
||||
message: id ? 'wallet updated' : 'wallet attached'
|
||||
message: id ? 'receive details updated' : 'wallet attached for receives'
|
||||
}
|
||||
}),
|
||||
models.walletLog.create({
|
||||
@ -640,7 +629,7 @@ async function upsertWallet (
|
||||
userId: me.id,
|
||||
wallet: wallet.type,
|
||||
level: enabled ? 'SUCCESS' : 'INFO',
|
||||
message: enabled ? 'wallet enabled' : 'wallet disabled'
|
||||
message: enabled ? 'receives enabled' : 'receives disabled'
|
||||
}
|
||||
})
|
||||
)
|
||||
|
@ -142,7 +142,11 @@ export function getGetServerSideProps (
|
||||
const { data: { me } } = await client.query({ query: 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 {
|
||||
redirect: {
|
||||
destination: `/signup?callbackUrl=${encodeURIComponent(callback)}`
|
||||
|
@ -71,6 +71,7 @@ export default gql`
|
||||
diagnostics: Boolean!
|
||||
noReferralLinks: Boolean!
|
||||
fiatCurrency: String!
|
||||
satsFilter: Int!
|
||||
greeterMode: Boolean!
|
||||
hideBookmarks: Boolean!
|
||||
hideCowboyHat: Boolean!
|
||||
@ -140,6 +141,7 @@ export default gql`
|
||||
diagnostics: Boolean!
|
||||
noReferralLinks: Boolean!
|
||||
fiatCurrency: String!
|
||||
satsFilter: Int!
|
||||
greeterMode: Boolean!
|
||||
hideBookmarks: Boolean!
|
||||
hideCowboyHat: Boolean!
|
||||
@ -192,7 +194,7 @@ export default gql`
|
||||
twitterId: String
|
||||
nostrAuthPubkey: String
|
||||
}
|
||||
|
||||
|
||||
type NameValue {
|
||||
name: String!
|
||||
value: Float!
|
||||
|
@ -2,19 +2,22 @@ import { gql } from 'graphql-tag'
|
||||
import { generateResolverName } from '@/lib/wallet'
|
||||
|
||||
import walletDefs from 'wallets/server'
|
||||
import { isServerField } from 'wallets'
|
||||
|
||||
function injectTypeDefs (typeDefs) {
|
||||
console.group('injected GraphQL type defs:')
|
||||
const injected = walletDefs.map(
|
||||
(w) => {
|
||||
let args = 'id: ID, '
|
||||
args += w.fields.map(f => {
|
||||
let arg = `${f.name}: String`
|
||||
if (!f.optional) {
|
||||
arg += '!'
|
||||
}
|
||||
return arg
|
||||
}).join(', ')
|
||||
args += w.fields
|
||||
.filter(isServerField)
|
||||
.map(f => {
|
||||
let arg = `${f.name}: String`
|
||||
if (!f.optional) {
|
||||
arg += '!'
|
||||
}
|
||||
return arg
|
||||
}).join(', ')
|
||||
args += ', settings: AutowithdrawSettings!'
|
||||
const resolverName = generateResolverName(w.walletField)
|
||||
const typeDef = `${resolverName}(${args}): Boolean`
|
||||
@ -74,7 +77,12 @@ const typeDefs = `
|
||||
cert: String
|
||||
}
|
||||
|
||||
union WalletDetails = WalletLNAddr | WalletLND | WalletCLN
|
||||
type WalletLNbits {
|
||||
url: String!
|
||||
invoiceKey: String!
|
||||
}
|
||||
|
||||
union WalletDetails = WalletLNAddr | WalletLND | WalletCLN | WalletLNbits
|
||||
|
||||
input AutowithdrawSettings {
|
||||
autoWithdrawThreshold: Int!
|
||||
|
@ -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
|
||||
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
|
||||
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
|
||||
|
|
@ -25,10 +25,15 @@ export function AutowithdrawSettings ({ wallet }) {
|
||||
setSendThreshold(Math.max(Math.floor(threshold / 10), 1))
|
||||
}, [autoWithdrawThreshold])
|
||||
|
||||
const [mounted, setMounted] = useState(false)
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Checkbox
|
||||
disabled={!wallet.isConfigured}
|
||||
disabled={mounted && !wallet.isConfigured}
|
||||
label='enabled'
|
||||
id='enabled'
|
||||
name='enabled'
|
||||
|
@ -97,7 +97,7 @@ export default function Comment ({
|
||||
}) {
|
||||
const [edit, setEdit] = useState()
|
||||
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(
|
||||
(isHiddenFreebie || item?.user?.meMute || (item?.outlawed && !me?.privates?.wildWestMode)) && !includeParent
|
||||
? 'yep'
|
||||
|
@ -243,9 +243,6 @@ export function useWalletLogger (wallet) {
|
||||
return
|
||||
}
|
||||
|
||||
// don't store logs for receiving wallets on client since logs are stored on server
|
||||
if (wallet.walletType) return
|
||||
|
||||
// TODO:
|
||||
// also send this to us if diagnostics was enabled,
|
||||
// very similar to how the service worker logger works.
|
||||
|
@ -35,6 +35,7 @@ services:
|
||||
- db:/var/lib/postgresql/data
|
||||
labels:
|
||||
CONNECT: "localhost:5431"
|
||||
cpu_shares: "${CPU_SHARES_IMPORTANT}"
|
||||
app:
|
||||
container_name: app
|
||||
stdin_open: true
|
||||
@ -58,6 +59,7 @@ services:
|
||||
- ./:/app
|
||||
labels:
|
||||
CONNECT: "localhost:3000"
|
||||
cpu_shares: "${CPU_SHARES_IMPORTANT}"
|
||||
capture:
|
||||
container_name: capture
|
||||
build:
|
||||
@ -79,6 +81,7 @@ services:
|
||||
- "5678:5678"
|
||||
labels:
|
||||
CONNECT: "localhost:5678"
|
||||
cpu_shares: "${CPU_SHARES_LOW}"
|
||||
worker:
|
||||
container_name: worker
|
||||
build:
|
||||
@ -97,6 +100,7 @@ services:
|
||||
entrypoint: ["/bin/sh", "-c"]
|
||||
command:
|
||||
- npm run worker:dev
|
||||
cpu_shares: "${CPU_SHARES_IMPORTANT}"
|
||||
imgproxy:
|
||||
container_name: imgproxy
|
||||
image: darthsim/imgproxy:v3.23.0
|
||||
@ -113,6 +117,7 @@ services:
|
||||
- "8080"
|
||||
labels:
|
||||
- "CONNECT=localhost:3001"
|
||||
cpu_shares: "${CPU_SHARES_LOW}"
|
||||
s3:
|
||||
container_name: s3
|
||||
image: localstack/localstack:s3-latest
|
||||
@ -138,6 +143,7 @@ services:
|
||||
- './docker/s3/cors.json:/etc/localstack/init/ready.d/cors.json'
|
||||
labels:
|
||||
- "CONNECT=localhost:4566"
|
||||
cpu_shares: "${CPU_SHARES_LOW}"
|
||||
opensearch:
|
||||
image: opensearchproject/opensearch:2.12.0
|
||||
container_name: opensearch
|
||||
@ -177,6 +183,7 @@ services:
|
||||
echo "OpenSearch index created."
|
||||
fg
|
||||
'
|
||||
cpu_shares: "${CPU_SHARES_LOW}"
|
||||
os-dashboard:
|
||||
image: opensearchproject/opensearch-dashboards:2.12.0
|
||||
container_name: os-dashboard
|
||||
@ -198,6 +205,7 @@ services:
|
||||
- opensearch
|
||||
labels:
|
||||
CONNECT: "localhost:5601"
|
||||
cpu_shares: "${CPU_SHARES_LOW}"
|
||||
bitcoin:
|
||||
image: polarlightning/bitcoind:26.0
|
||||
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}
|
||||
fi
|
||||
'
|
||||
cpu_shares: "${CPU_SHARES_MODERATE}"
|
||||
sn_lnd:
|
||||
build:
|
||||
context: ./docker/lnd
|
||||
@ -311,6 +320,7 @@ services:
|
||||
--min_confs 0 --local_amt=1000000000 --push_amt=500000000
|
||||
fi
|
||||
"
|
||||
cpu_shares: "${CPU_SHARES_MODERATE}"
|
||||
stacker_lnd:
|
||||
build:
|
||||
context: ./docker/lnd
|
||||
@ -370,6 +380,7 @@ services:
|
||||
--min_confs 0 --local_amt=1000000000 --push_amt=500000000
|
||||
fi
|
||||
"
|
||||
cpu_shares: "${CPU_SHARES_MODERATE}"
|
||||
litd:
|
||||
container_name: litd
|
||||
build:
|
||||
@ -404,6 +415,7 @@ services:
|
||||
- '--loop.server.host=test.swap.lightning.today:11010'
|
||||
labels:
|
||||
CONNECT: "localhost:8443"
|
||||
cpu_shares: "${CPU_SHARES_MODERATE}"
|
||||
stacker_cln:
|
||||
build:
|
||||
context: ./docker/cln
|
||||
@ -446,6 +458,7 @@ services:
|
||||
amount=1000000000 push_msat=500000000000 minconf=0
|
||||
fi
|
||||
"
|
||||
cpu_shares: "${CPU_SHARES_MODERATE}"
|
||||
channdler:
|
||||
image: mcuadros/ofelia:latest
|
||||
container_name: channdler
|
||||
@ -460,6 +473,7 @@ services:
|
||||
command: daemon --docker -f label=com.docker.compose.project=${COMPOSE_PROJECT_NAME}
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
cpu_shares: "${CPU_SHARES_LOW}"
|
||||
mailhog:
|
||||
image: mailhog/mailhog:latest
|
||||
container_name: mailhog
|
||||
@ -476,6 +490,7 @@ services:
|
||||
- app
|
||||
labels:
|
||||
CONNECT: "localhost:8025"
|
||||
cpu_shares: "${CPU_SHARES_LOW}"
|
||||
nwc:
|
||||
build:
|
||||
context: ./docker/nwc
|
||||
@ -507,6 +522,7 @@ services:
|
||||
- '0'
|
||||
- '--daily-limit'
|
||||
- '0'
|
||||
cpu_shares: "${CPU_SHARES_LOW}"
|
||||
lnbits:
|
||||
image: lnbits/lnbits:0.12.5
|
||||
container_name: lnbits
|
||||
@ -525,6 +541,9 @@ services:
|
||||
- LND_GRPC_MACAROON=/app/.lnd/regtest/admin.macaroon
|
||||
volumes:
|
||||
- ./docker/lnd/stacker:/app/.lnd
|
||||
labels:
|
||||
CONNECT: "localhost:${LNBITS_WEB_PORT}"
|
||||
cpu_shares: "${CPU_SHARES_LOW}"
|
||||
volumes:
|
||||
db:
|
||||
os:
|
||||
|
@ -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
|
@ -15,7 +15,7 @@ export const ME = gql`
|
||||
diagnostics
|
||||
noReferralLinks
|
||||
fiatCurrency
|
||||
greeterMode
|
||||
satsFilter
|
||||
hideCowboyHat
|
||||
hideFromTopUsers
|
||||
hideGithub
|
||||
@ -104,7 +104,7 @@ export const SETTINGS_FIELDS = gql`
|
||||
nostrCrossposting
|
||||
nostrRelays
|
||||
wildWestMode
|
||||
greeterMode
|
||||
satsFilter
|
||||
nsfwMode
|
||||
authMethods {
|
||||
lightning
|
||||
|
@ -129,6 +129,10 @@ export const WALLET = gql`
|
||||
rune
|
||||
cert
|
||||
}
|
||||
... on WalletLNbits {
|
||||
url
|
||||
invoiceKey
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -157,6 +161,10 @@ export const WALLET_BY_TYPE = gql`
|
||||
rune
|
||||
cert
|
||||
}
|
||||
... on WalletLNbits {
|
||||
url
|
||||
invoiceKey
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -6,7 +6,7 @@ import {
|
||||
} from './constants'
|
||||
import { SUPPORTED_CURRENCIES } from './currency'
|
||||
import { NOSTR_MAX_RELAY_NUM, NOSTR_PUBKEY_BECH32, NOSTR_PUBKEY_HEX } from './nostr'
|
||||
import { msatsToSats, numWithUnits, abbrNum, ensureB64, B64_URL_REGEX } from './format'
|
||||
import { msatsToSats, numWithUnits, abbrNum, ensureB64, B64_URL_REGEX, HEX_REGEX } from './format'
|
||||
import * as usersFragments from '@/fragments/users'
|
||||
import * as subsFragments from '@/fragments/subs'
|
||||
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) {
|
||||
return this.test({
|
||||
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(
|
||||
MAX_TITLE_LENGTH,
|
||||
({ max, value }) => `-${Math.abs(max - value.length)} characters remaining`
|
||||
@ -579,6 +595,7 @@ export const settingsSchema = object().shape({
|
||||
diagnostics: boolean(),
|
||||
noReferralLinks: 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')
|
||||
// exclude from cyclic analysis. see https://github.com/jquense/yup/issues/720
|
||||
}, [['tipRandomMax', 'tipRandomMin']])
|
||||
@ -620,7 +637,7 @@ export const lnAddrSchema = ({ payerData, min, max, commentAllowed } = {}) =>
|
||||
return accum
|
||||
}, {})))
|
||||
|
||||
export const lnbitsSchema = object({
|
||||
export const lnbitsSchema = object().shape({
|
||||
url: process.env.NODE_ENV === 'development'
|
||||
? string()
|
||||
.or([string().matches(/^(http:\/\/)?localhost:\d+$/), string().url()], 'invalid url')
|
||||
@ -642,8 +659,25 @@ export const lnbitsSchema = object({
|
||||
}
|
||||
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({
|
||||
nwcUrl: string()
|
||||
|
@ -140,7 +140,7 @@ export default function Settings ({ ssrData }) {
|
||||
hideTwitter: settings?.hideTwitter,
|
||||
imgproxyOnly: settings?.imgproxyOnly,
|
||||
wildWestMode: settings?.wildWestMode,
|
||||
greeterMode: settings?.greeterMode,
|
||||
satsFilter: settings?.satsFilter,
|
||||
nsfwMode: settings?.nsfwMode,
|
||||
nostrPubkey: settings?.nostrPubkey ? bech32encode(settings.nostrPubkey) : '',
|
||||
nostrCrossposting: settings?.nostrCrossposting,
|
||||
@ -152,7 +152,11 @@ export default function Settings ({ ssrData }) {
|
||||
noReferralLinks: settings?.noReferralLinks
|
||||
}}
|
||||
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) {
|
||||
nostrPubkey = null
|
||||
} else {
|
||||
@ -172,6 +176,7 @@ export default function Settings ({ ssrData }) {
|
||||
tipRandomMin: tipRandom ? Number(tipRandomMin) : null,
|
||||
tipRandomMax: tipRandom ? Number(tipRandomMax) : null,
|
||||
withdrawMaxFeeDefault: Number(withdrawMaxFeeDefault),
|
||||
satsFilter: Number(satsFilter),
|
||||
zapUndos: zapUndosEnabled ? Number(zapUndos) : null,
|
||||
nostrPubkey,
|
||||
nostrRelays: nostrRelaysFiltered,
|
||||
@ -467,7 +472,27 @@ export default function Settings ({ ssrData }) {
|
||||
label={<>don't create referral links on copy</>}
|
||||
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
|
||||
label={
|
||||
<div className='d-flex align-items-center'>wild west mode
|
||||
@ -482,21 +507,6 @@ export default function Settings ({ ssrData }) {
|
||||
name='wildWestMode'
|
||||
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
|
||||
label={
|
||||
<div className='d-flex align-items-center'>nsfw mode
|
||||
|
@ -57,15 +57,11 @@ export default function WalletSettings () {
|
||||
|
||||
await wallet.save(values)
|
||||
|
||||
if (values.enabled) wallet.enable()
|
||||
else wallet.disable()
|
||||
|
||||
toaster.success('saved settings')
|
||||
router.push('/settings/wallets')
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
const message = 'failed to attach: ' + err.message || err.toString?.()
|
||||
toaster.danger(message)
|
||||
toaster.danger(err.message || err.toString?.())
|
||||
}
|
||||
}}
|
||||
>
|
||||
@ -106,12 +102,12 @@ export default function WalletSettings () {
|
||||
|
||||
function WalletFields ({ wallet: { config, fields, isConfigured } }) {
|
||||
return fields
|
||||
.map(({ name, label, type, help, optional, editable, ...props }, i) => {
|
||||
.map(({ name, label, type, help, optional, editable, clientOnly, serverOnly, ...props }, i) => {
|
||||
const rawProps = {
|
||||
...props,
|
||||
name,
|
||||
initialValue: config?.[name],
|
||||
readOnly: isConfigured && editable === false,
|
||||
readOnly: isConfigured && editable === false && !!config?.[name],
|
||||
groupClassName: props.hidden ? 'd-none' : undefined,
|
||||
label: label
|
||||
? (
|
||||
|
24
prisma/migrations/20240729195320_lnbits_recv/migration.sql
Normal file
24
prisma/migrations/20240729195320_lnbits_recv/migration.sql
Normal 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();
|
14
prisma/migrations/20240808234214_sats_filter/migration.sql
Normal file
14
prisma/migrations/20240808234214_sats_filter/migration.sql
Normal 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");
|
@ -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
|
||||
);
|
@ -54,7 +54,7 @@ model User {
|
||||
upvoteTrust Float @default(0)
|
||||
hideInvoiceDesc Boolean @default(false)
|
||||
wildWestMode Boolean @default(false)
|
||||
greeterMode Boolean @default(false)
|
||||
satsFilter Int @default(10)
|
||||
nsfwMode Boolean @default(false)
|
||||
fiatCurrency String @default("USD")
|
||||
withdrawMaxFeeDefault Int @default(10)
|
||||
@ -66,6 +66,7 @@ model User {
|
||||
hideWalletBalance Boolean @default(false)
|
||||
referrerId Int?
|
||||
nostrPubkey String?
|
||||
greeterMode Boolean @default(false)
|
||||
nostrAuthPubkey String? @unique(map: "users.nostrAuthPubkey_unique")
|
||||
nostrCrossposting Boolean @default(false)
|
||||
slashtagId String? @unique(map: "users.slashtagId_unique")
|
||||
@ -167,6 +168,7 @@ enum WalletType {
|
||||
LIGHTNING_ADDRESS
|
||||
LND
|
||||
CLN
|
||||
LNBITS
|
||||
}
|
||||
|
||||
model Wallet {
|
||||
@ -190,6 +192,7 @@ model Wallet {
|
||||
walletLightningAddress WalletLightningAddress?
|
||||
walletLND WalletLND?
|
||||
walletCLN WalletCLN?
|
||||
walletLNbits WalletLNbits?
|
||||
withdrawals Withdrawl[]
|
||||
|
||||
@@index([userId])
|
||||
@ -238,6 +241,16 @@ model WalletCLN {
|
||||
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 {
|
||||
muterId Int
|
||||
mutedId Int
|
||||
@ -425,6 +438,7 @@ model Item {
|
||||
lastZapAt DateTime?
|
||||
ncomments Int @default(0)
|
||||
msats BigInt @default(0)
|
||||
cost Int @default(0)
|
||||
weightedDownVotes Float @default(0)
|
||||
bio Boolean @default(false)
|
||||
freebie Boolean @default(false)
|
||||
@ -489,6 +503,7 @@ model Item {
|
||||
@@index([weightedVotes], map: "Item.weightedVotes_index")
|
||||
@@index([invoiceId])
|
||||
@@index([invoiceActionState])
|
||||
@@index([cost])
|
||||
}
|
||||
|
||||
// we use this to denormalize a user's aggregated interactions (zaps) with an item
|
||||
|
193
wallets/index.js
193
wallets/index.js
@ -1,6 +1,6 @@
|
||||
import { useCallback } from 'react'
|
||||
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 { SSR } from '@/lib/constants'
|
||||
import { bolt11Tags } from '@/lib/bolt11'
|
||||
@ -12,6 +12,7 @@ import { autowithdrawInitial } from '@/components/autowithdraw-shared'
|
||||
import { useShowModal } from '@/components/modal'
|
||||
import { useToast } from '../components/toast'
|
||||
import { generateResolverName } from '@/lib/wallet'
|
||||
import { walletValidate } from '@/lib/validate'
|
||||
|
||||
export const Status = {
|
||||
Initialized: 'Initialized',
|
||||
@ -32,6 +33,22 @@ export function useWallet (name) {
|
||||
const hasConfig = wallet?.fields.length > 0
|
||||
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 enabled = status === Status.Enabled
|
||||
const priority = config?.priority
|
||||
@ -49,53 +66,40 @@ export function useWallet (name) {
|
||||
}
|
||||
}, [me, wallet, config, logger, status])
|
||||
|
||||
const enable = useCallback(() => {
|
||||
enableWallet(name, me)
|
||||
logger.ok('wallet enabled')
|
||||
}, [name, me, logger])
|
||||
|
||||
const disable = useCallback(() => {
|
||||
disableWallet(name, me)
|
||||
logger.info('wallet disabled')
|
||||
}, [name, me, logger])
|
||||
|
||||
const setPriority = useCallback(async (priority) => {
|
||||
if (_isConfigured && priority !== config.priority) {
|
||||
try {
|
||||
await saveConfig({ ...config, priority })
|
||||
await saveConfig({ ...config, priority }, { logger })
|
||||
} catch (err) {
|
||||
toaster.danger(`failed to change priority of ${wallet.name} wallet: ${err.message}`)
|
||||
}
|
||||
}
|
||||
}, [wallet, config, logger, toaster])
|
||||
}, [wallet, config, toaster])
|
||||
|
||||
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 {
|
||||
// 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')
|
||||
validConfig = await wallet.testConnectClient?.(newConfig, { me, logger })
|
||||
} catch (err) {
|
||||
const message = err.message || err.toString?.()
|
||||
logger.error('failed to attach: ' + message)
|
||||
logger.error(err.message)
|
||||
throw err
|
||||
}
|
||||
}, [_isConfigured, saveConfig, me, logger])
|
||||
await saveConfig(validConfig ?? newConfig, { logger })
|
||||
}, [saveConfig, me, logger])
|
||||
|
||||
// delete is a reserved keyword
|
||||
const delete_ = useCallback(async () => {
|
||||
try {
|
||||
await clearConfig()
|
||||
logger.ok('wallet detached')
|
||||
disable()
|
||||
await clearConfig({ logger })
|
||||
} catch (err) {
|
||||
const message = err.message || err.toString?.()
|
||||
logger.error(message)
|
||||
throw err
|
||||
}
|
||||
}, [clearConfig, logger, disable])
|
||||
}, [clearConfig, logger, disablePayments])
|
||||
|
||||
if (!wallet) return null
|
||||
|
||||
@ -106,11 +110,8 @@ export function useWallet (name) {
|
||||
save,
|
||||
delete: delete_,
|
||||
deleteLogs,
|
||||
enable,
|
||||
disable,
|
||||
setPriority,
|
||||
hasConfig,
|
||||
isConfigured: _isConfigured,
|
||||
status,
|
||||
enabled,
|
||||
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) {
|
||||
const me = useMe()
|
||||
|
||||
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 hasLocalConfig = !!wallet?.sendPayment
|
||||
const hasClientConfig = !!wallet?.sendPayment
|
||||
const hasServerConfig = !!wallet?.walletType
|
||||
|
||||
const config = {
|
||||
// only include config if it makes sense for this wallet
|
||||
// since server config always returns default values for autowithdraw settings
|
||||
// which might be confusing to have for wallets that don't support autowithdraw
|
||||
...(hasLocalConfig ? localConfig : {}),
|
||||
...(hasServerConfig ? serverConfig : {})
|
||||
let config = {}
|
||||
if (hasClientConfig) config = clientConfig
|
||||
if (hasServerConfig) {
|
||||
const { enabled } = config || {}
|
||||
config = {
|
||||
...config,
|
||||
...serverConfig
|
||||
}
|
||||
// wallet is enabled if enabled is set in client or server config
|
||||
config.enabled ||= enabled
|
||||
}
|
||||
|
||||
const saveConfig = useCallback(async (config) => {
|
||||
if (hasLocalConfig) setLocalConfig(config)
|
||||
if (hasServerConfig) await setServerConfig(config)
|
||||
}, [wallet])
|
||||
const saveConfig = useCallback(async (newConfig, { logger }) => {
|
||||
// NOTE:
|
||||
// verifying the client/server configuration before saving it
|
||||
// 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 () => {
|
||||
if (hasLocalConfig) clearLocalConfig()
|
||||
let valid = true
|
||||
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()
|
||||
}, [wallet])
|
||||
}, [hasClientConfig, hasServerConfig, clearClientConfig, clearServerConfig, wallet])
|
||||
|
||||
return [config, saveConfig, clearConfig]
|
||||
}
|
||||
@ -174,6 +252,8 @@ function useServerConfig (wallet) {
|
||||
enabled: data?.walletByType?.enabled,
|
||||
...data?.walletByType?.wallet
|
||||
}
|
||||
delete serverConfig.__typename
|
||||
|
||||
const autowithdrawSettings = autowithdrawInitial({ me })
|
||||
const config = { ...serverConfig, ...autowithdrawSettings }
|
||||
|
||||
@ -189,8 +269,8 @@ function useServerConfig (wallet) {
|
||||
return await client.mutate({
|
||||
mutation,
|
||||
variables: {
|
||||
id: walletId,
|
||||
...config,
|
||||
id: walletId,
|
||||
settings: {
|
||||
autoWithdrawThreshold: Number(autoWithdrawThreshold),
|
||||
autoWithdrawMaxFeePercent: Number(autoWithdrawMaxFeePercent),
|
||||
@ -206,6 +286,9 @@ function useServerConfig (wallet) {
|
||||
}, [client, walletId])
|
||||
|
||||
const clearConfig = useCallback(async () => {
|
||||
// only remove wallet if there is a wallet to remove
|
||||
if (!walletId) return
|
||||
|
||||
try {
|
||||
await client.mutate({
|
||||
mutation: REMOVE_WALLET,
|
||||
@ -224,17 +307,21 @@ function generateMutation (wallet) {
|
||||
const resolverName = generateResolverName(wallet.walletField)
|
||||
|
||||
let headerArgs = '$id: ID, '
|
||||
headerArgs += wallet.fields.map(f => {
|
||||
let arg = `$${f.name}: String`
|
||||
if (!f.optional) {
|
||||
arg += '!'
|
||||
}
|
||||
return arg
|
||||
}).join(', ')
|
||||
headerArgs += wallet.fields
|
||||
.filter(isServerField)
|
||||
.map(f => {
|
||||
let arg = `$${f.name}: String`
|
||||
if (!f.optional) {
|
||||
arg += '!'
|
||||
}
|
||||
return arg
|
||||
}).join(', ')
|
||||
headerArgs += ', $settings: AutowithdrawSettings!'
|
||||
|
||||
let inputArgs = 'id: $id, '
|
||||
inputArgs += wallet.fields.map(f => `${f.name}: $${f.name}`).join(', ')
|
||||
inputArgs += wallet.fields
|
||||
.filter(isServerField)
|
||||
.map(f => `${f.name}: $${f.name}`).join(', ')
|
||||
inputArgs += ', settings: $settings'
|
||||
|
||||
return gql`mutation ${resolverName}(${headerArgs}) {
|
||||
|
27
wallets/lnbits/ATTACH.md
Normal file
27
wallets/lnbits/ATTACH.md
Normal 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()
|
||||
> ```
|
||||
>
|
@ -1,10 +1,10 @@
|
||||
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')
|
||||
|
||||
url = url.replace(/\/+$/, '')
|
||||
await getWallet({ url, adminKey })
|
||||
await getWallet({ url, adminKey, invoiceKey })
|
||||
|
||||
logger.ok('wallet found')
|
||||
}
|
||||
@ -23,13 +23,13 @@ export async function sendPayment (bolt11, { url, adminKey }) {
|
||||
return { preimage }
|
||||
}
|
||||
|
||||
async function getWallet ({ url, adminKey }) {
|
||||
async function getWallet ({ url, adminKey, invoiceKey }) {
|
||||
const path = '/api/v1/wallet'
|
||||
|
||||
const headers = new Headers()
|
||||
headers.append('Accept', 'application/json')
|
||||
headers.append('Content-Type', 'application/json')
|
||||
headers.append('X-Api-Key', adminKey)
|
||||
headers.append('X-Api-Key', adminKey || invoiceKey)
|
||||
|
||||
const res = await fetch(url + path, { method: 'GET', headers })
|
||||
if (!res.ok) {
|
||||
|
@ -8,17 +8,32 @@ export const fields = [
|
||||
label: 'lnbits url',
|
||||
type: 'text'
|
||||
},
|
||||
{
|
||||
name: 'invoiceKey',
|
||||
label: 'invoice key',
|
||||
type: 'password',
|
||||
optional: 'for receiving',
|
||||
serverOnly: true,
|
||||
editable: false
|
||||
},
|
||||
{
|
||||
name: 'adminKey',
|
||||
label: 'admin key',
|
||||
type: 'password'
|
||||
type: 'password',
|
||||
optional: 'for sending',
|
||||
clientOnly: true,
|
||||
editable: false
|
||||
}
|
||||
]
|
||||
|
||||
export const card = {
|
||||
title: 'LNbits',
|
||||
subtitle: 'use [LNbits](https://lnbits.com/) for payments',
|
||||
badges: ['send only']
|
||||
badges: ['send & receive']
|
||||
}
|
||||
|
||||
export const fieldValidation = lnbitsSchema
|
||||
|
||||
export const walletType = 'LNBITS'
|
||||
|
||||
export const walletField = 'walletLNbits'
|
||||
|
30
wallets/lnbits/server.js
Normal file
30
wallets/lnbits/server.js
Normal 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 })
|
||||
}
|
@ -1,5 +1,6 @@
|
||||
import * as lnd from 'wallets/lnd/server'
|
||||
import * as cln from 'wallets/cln/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]
|
||||
|
Loading…
x
Reference in New Issue
Block a user