Compare commits
14 Commits
00ca35465c
...
7d587c7cf8
Author | SHA1 | Date | |
---|---|---|---|
|
7d587c7cf8 | ||
|
6a13c22ad2 | ||
|
bc85a63091 | ||
|
058f88da49 | ||
|
a0f3e338a8 | ||
|
052e36f6ed | ||
|
8eee1c2a71 | ||
|
02fe4d5d92 | ||
|
1a25179a98 | ||
|
e30dfbae57 | ||
|
c19c9124ec | ||
|
927eaa8c5b | ||
|
dec4452d62 | ||
|
796bd4dc4b |
@ -1,6 +1,6 @@
|
|||||||
############################################################################
|
############################################################################
|
||||||
# AUTH / OPTIONAL #
|
# OPTIONAL SECRETS #
|
||||||
# if you want to work on logged in features, you'll need some kind of auth #
|
# put these in .env.local, and don't commit them to git #
|
||||||
############################################################################
|
############################################################################
|
||||||
|
|
||||||
# github
|
# github
|
||||||
@ -11,16 +11,9 @@ GITHUB_SECRET=
|
|||||||
TWITTER_ID=
|
TWITTER_ID=
|
||||||
TWITTER_SECRET=
|
TWITTER_SECRET=
|
||||||
|
|
||||||
# email
|
# email list
|
||||||
LOGIN_EMAIL_SERVER=smtp://mailhog:1025
|
|
||||||
LOGIN_EMAIL_FROM=sndev@mailhog.dev
|
|
||||||
LIST_MONK_AUTH=
|
LIST_MONK_AUTH=
|
||||||
|
|
||||||
########################################################
|
|
||||||
# OTHER / OPTIONAL #
|
|
||||||
# configuration for push notifications, slack are here #
|
|
||||||
########################################################
|
|
||||||
|
|
||||||
# VAPID for Web Push
|
# VAPID for Web Push
|
||||||
VAPID_MAILTO=
|
VAPID_MAILTO=
|
||||||
NEXT_PUBLIC_VAPID_PUBKEY=
|
NEXT_PUBLIC_VAPID_PUBKEY=
|
||||||
@ -34,9 +27,14 @@ SLACK_CHANNEL_ID=
|
|||||||
LNAUTH_URL=
|
LNAUTH_URL=
|
||||||
LNWITH_URL=
|
LNWITH_URL=
|
||||||
|
|
||||||
#########################
|
########################################
|
||||||
# SNDEV STUFF WE PRESET #
|
# SNDEV STUFF WE PRESET #
|
||||||
#########################
|
# which you can override in .env.local #
|
||||||
|
########################################
|
||||||
|
|
||||||
|
# email
|
||||||
|
LOGIN_EMAIL_SERVER=smtp://mailhog:1025
|
||||||
|
LOGIN_EMAIL_FROM=sndev@mailhog.dev
|
||||||
|
|
||||||
# static things
|
# static things
|
||||||
NEXTAUTH_URL=http://localhost:3000/api/auth
|
NEXTAUTH_URL=http://localhost:3000/api/auth
|
||||||
@ -94,10 +92,6 @@ NEXT_PUBLIC_NORMAL_POLL_INTERVAL=30000
|
|||||||
NEXT_PUBLIC_LONG_POLL_INTERVAL=60000
|
NEXT_PUBLIC_LONG_POLL_INTERVAL=60000
|
||||||
NEXT_PUBLIC_EXTRA_LONG_POLL_INTERVAL=300000
|
NEXT_PUBLIC_EXTRA_LONG_POLL_INTERVAL=300000
|
||||||
|
|
||||||
###################
|
|
||||||
# FOR DOCKER ONLY #
|
|
||||||
###################
|
|
||||||
|
|
||||||
# containers can't use localhost, so we need to use the container name
|
# containers can't use localhost, so we need to use the container name
|
||||||
IMGPROXY_URL_DOCKER=http://imgproxy:8080
|
IMGPROXY_URL_DOCKER=http://imgproxy:8080
|
||||||
MEDIA_URL_DOCKER=http://s3:4566/uploads
|
MEDIA_URL_DOCKER=http://s3:4566/uploads
|
18
.env.production
Normal file
18
.env.production
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
LIST_MONK_URL=https://mail.stacker.news
|
||||||
|
LNAUTH_URL=https://stacker.news/api/lnauth
|
||||||
|
LND_CONNECT_ADDRESS=03cc1d0932bb99b0697f5b5e5961b83ab7fd66f1efc4c9f5c7bad66c1bcbe78f02@52.5.194.83:9735
|
||||||
|
LNWITH_URL=https://stacker.news/api/lnwith
|
||||||
|
LOGIN_EMAIL_FROM=login@stacker.news
|
||||||
|
NEXTAUTH_URL=https://stacker.news
|
||||||
|
NEXTAUTH_URL_INTERNAL=http://127.0.0.1:8080/api/auth
|
||||||
|
NEXT_PUBLIC_AWS_UPLOAD_BUCKET=snuploads
|
||||||
|
NEXT_PUBLIC_IMGPROXY_URL=https://imgprxy.stacker.news/
|
||||||
|
NEXT_PUBLIC_MEDIA_DOMAIN=m.stacker.news
|
||||||
|
PUBLIC_URL=https://stacker.news
|
||||||
|
SELF_URL=http://127.0.0.1:8080
|
||||||
|
grpc_proxy=http://127.0.0.1:7050
|
||||||
|
NEXT_PUBLIC_FAST_POLL_INTERVAL=1000
|
||||||
|
NEXT_PUBLIC_NORMAL_POLL_INTERVAL=30000
|
||||||
|
NEXT_PUBLIC_LONG_POLL_INTERVAL=60000
|
||||||
|
NEXT_PUBLIC_EXTRA_LONG_POLL_INTERVAL=300000
|
||||||
|
NEXT_PUBLIC_URL=https://stacker.news
|
4
.gitignore
vendored
4
.gitignore
vendored
@ -28,9 +28,9 @@ yarn-debug.log*
|
|||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
|
||||||
# local env files
|
# local env files
|
||||||
envbak
|
|
||||||
.env*
|
.env*
|
||||||
!.env.sample
|
!.env.development
|
||||||
|
!.env.production
|
||||||
|
|
||||||
# local settings
|
# local settings
|
||||||
.vscode/settings.json
|
.vscode/settings.json
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { GraphQLError } from 'graphql'
|
import { GraphQLError } from 'graphql'
|
||||||
import { decodeCursor, LIMIT, nextNoteCursorEncoded } from '@/lib/cursor'
|
import { decodeCursor, LIMIT, nextNoteCursorEncoded } from '@/lib/cursor'
|
||||||
import { getItem, filterClause, whereClause, muteClause } from './item'
|
import { getItem, filterClause, whereClause, muteClause } from './item'
|
||||||
import { getInvoice } from './wallet'
|
import { getInvoice, getWithdrawl } from './wallet'
|
||||||
import { pushSubscriptionSchema, ssValidate } from '@/lib/validate'
|
import { pushSubscriptionSchema, ssValidate } from '@/lib/validate'
|
||||||
import { replyToSubscription } from '@/lib/webPush'
|
import { replyToSubscription } from '@/lib/webPush'
|
||||||
import { getSub } from './sub'
|
import { getSub } from './sub'
|
||||||
@ -444,6 +444,9 @@ export default {
|
|||||||
InvoicePaid: {
|
InvoicePaid: {
|
||||||
invoice: async (n, args, { me, models }) => getInvoice(n, { id: n.id }, { me, models })
|
invoice: async (n, args, { me, models }) => getInvoice(n, { id: n.id }, { me, models })
|
||||||
},
|
},
|
||||||
|
WithdrawlPaid: {
|
||||||
|
withdrawl: async (n, args, { me, models }) => getWithdrawl(n, { id: n.id }, { me, models })
|
||||||
|
},
|
||||||
Invitification: {
|
Invitification: {
|
||||||
invite: async (n, args, { models }) => {
|
invite: async (n, args, { models }) => {
|
||||||
return await models.invite.findUnique({
|
return await models.invite.findUnique({
|
||||||
|
@ -55,6 +55,31 @@ export async function getInvoice (parent, { id }, { me, models, lnd }) {
|
|||||||
return inv
|
return inv
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getWithdrawl (parent, { id }, { me, models }) {
|
||||||
|
if (!me) {
|
||||||
|
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const wdrwl = await models.withdrawl.findUnique({
|
||||||
|
where: {
|
||||||
|
id: Number(id)
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!wdrwl) {
|
||||||
|
throw new GraphQLError('withdrawal not found', { extensions: { code: 'BAD_INPUT' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wdrwl.user.id !== me.id) {
|
||||||
|
throw new GraphQLError('not ur withdrawal', { extensions: { code: 'FORBIDDEN' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
return wdrwl
|
||||||
|
}
|
||||||
|
|
||||||
export function createHmac (hash) {
|
export function createHmac (hash) {
|
||||||
const key = Buffer.from(process.env.INVOICE_HMAC_KEY, 'hex')
|
const key = Buffer.from(process.env.INVOICE_HMAC_KEY, 'hex')
|
||||||
return crypto.createHmac('sha256', key).update(Buffer.from(hash, 'hex')).digest('hex')
|
return crypto.createHmac('sha256', key).update(Buffer.from(hash, 'hex')).digest('hex')
|
||||||
@ -99,30 +124,7 @@ export default {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
withdrawl: async (parent, { id }, { me, models }) => {
|
withdrawl: getWithdrawl,
|
||||||
if (!me) {
|
|
||||||
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
|
|
||||||
}
|
|
||||||
|
|
||||||
const wdrwl = await models.withdrawl.findUnique({
|
|
||||||
where: {
|
|
||||||
id: Number(id)
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
user: true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!wdrwl) {
|
|
||||||
throw new GraphQLError('withdrawal not found', { extensions: { code: 'BAD_INPUT' } })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (wdrwl.user.id !== me.id) {
|
|
||||||
throw new GraphQLError('not ur withdrawal', { extensions: { code: 'FORBIDDEN' } })
|
|
||||||
}
|
|
||||||
|
|
||||||
return wdrwl
|
|
||||||
},
|
|
||||||
numBolt11s: async (parent, args, { me, models, lnd }) => {
|
numBolt11s: async (parent, args, { me, models, lnd }) => {
|
||||||
if (!me) {
|
if (!me) {
|
||||||
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
|
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
|
||||||
@ -435,12 +437,11 @@ export default {
|
|||||||
data.macaroon = ensureB64(data.macaroon)
|
data.macaroon = ensureB64(data.macaroon)
|
||||||
data.cert = ensureB64(data.cert)
|
data.cert = ensureB64(data.cert)
|
||||||
|
|
||||||
const wallet = 'walletLND'
|
const walletType = 'LND'
|
||||||
return await upsertWallet(
|
return await upsertWallet(
|
||||||
{
|
{
|
||||||
schema: LNDAutowithdrawSchema,
|
schema: LNDAutowithdrawSchema,
|
||||||
walletName: wallet,
|
walletType,
|
||||||
walletType: 'LND',
|
|
||||||
testConnect: async ({ cert, macaroon, socket }) => {
|
testConnect: async ({ cert, macaroon, socket }) => {
|
||||||
try {
|
try {
|
||||||
const { lnd } = await authenticatedLndGrpc({
|
const { lnd } = await authenticatedLndGrpc({
|
||||||
@ -455,12 +456,12 @@ export default {
|
|||||||
expires_at: new Date()
|
expires_at: new Date()
|
||||||
})
|
})
|
||||||
// we wrap both calls in one try/catch since connection attempts happen on RPC calls
|
// we wrap both calls in one try/catch since connection attempts happen on RPC calls
|
||||||
await addWalletLog({ wallet, level: 'SUCCESS', message: 'connected to LND' }, { me, models })
|
await addWalletLog({ wallet: walletType, level: 'SUCCESS', message: 'connected to LND' }, { me, models })
|
||||||
return inv
|
return inv
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// LND errors are in this shape: [code, type, { err: { code, details, metadata } }]
|
// LND errors are in this shape: [code, type, { err: { code, details, metadata } }]
|
||||||
const details = err[2]?.err?.details || err.message || err.toString?.()
|
const details = err[2]?.err?.details || err.message || err.toString?.()
|
||||||
await addWalletLog({ wallet, level: 'ERROR', message: `could not connect to LND: ${details}` }, { me, models })
|
await addWalletLog({ wallet: walletType, level: 'ERROR', message: `could not connect to LND: ${details}` }, { me, models })
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -470,12 +471,11 @@ export default {
|
|||||||
upsertWalletCLN: async (parent, { settings, ...data }, { me, models }) => {
|
upsertWalletCLN: async (parent, { settings, ...data }, { me, models }) => {
|
||||||
data.cert = ensureB64(data.cert)
|
data.cert = ensureB64(data.cert)
|
||||||
|
|
||||||
const wallet = 'walletCLN'
|
const walletType = 'CLN'
|
||||||
return await upsertWallet(
|
return await upsertWallet(
|
||||||
{
|
{
|
||||||
schema: CLNAutowithdrawSchema,
|
schema: CLNAutowithdrawSchema,
|
||||||
walletName: wallet,
|
walletType,
|
||||||
walletType: 'CLN',
|
|
||||||
testConnect: async ({ socket, rune, cert }) => {
|
testConnect: async ({ socket, rune, cert }) => {
|
||||||
try {
|
try {
|
||||||
const inv = await createInvoiceCLN({
|
const inv = await createInvoiceCLN({
|
||||||
@ -486,11 +486,11 @@ export default {
|
|||||||
msats: 'any',
|
msats: 'any',
|
||||||
expiry: 0
|
expiry: 0
|
||||||
})
|
})
|
||||||
await addWalletLog({ wallet, level: 'SUCCESS', message: 'connected to CLN' }, { me, models })
|
await addWalletLog({ wallet: walletType, level: 'SUCCESS', message: 'connected to CLN' }, { me, models })
|
||||||
return inv
|
return inv
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const details = err.details || err.message || err.toString?.()
|
const details = err.details || err.message || err.toString?.()
|
||||||
await addWalletLog({ wallet, level: 'ERROR', message: `could not connect to CLN: ${details}` }, { me, models })
|
await addWalletLog({ wallet: walletType, level: 'ERROR', message: `could not connect to CLN: ${details}` }, { me, models })
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -498,15 +498,14 @@ export default {
|
|||||||
{ settings, data }, { me, models })
|
{ settings, data }, { me, models })
|
||||||
},
|
},
|
||||||
upsertWalletLNAddr: async (parent, { settings, ...data }, { me, models }) => {
|
upsertWalletLNAddr: async (parent, { settings, ...data }, { me, models }) => {
|
||||||
const wallet = 'walletLightningAddress'
|
const walletType = 'LIGHTNING_ADDRESS'
|
||||||
return await upsertWallet(
|
return await upsertWallet(
|
||||||
{
|
{
|
||||||
schema: lnAddrAutowithdrawSchema,
|
schema: lnAddrAutowithdrawSchema,
|
||||||
walletName: wallet,
|
walletType,
|
||||||
walletType: 'LIGHTNING_ADDRESS',
|
|
||||||
testConnect: async ({ address }) => {
|
testConnect: async ({ address }) => {
|
||||||
const options = await lnAddrOptions(address)
|
const options = await lnAddrOptions(address)
|
||||||
await addWalletLog({ wallet, level: 'SUCCESS', message: 'fetched payment details' }, { me, models })
|
await addWalletLog({ wallet: walletType, level: 'SUCCESS', message: 'fetched payment details' }, { me, models })
|
||||||
return options
|
return options
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -522,19 +521,9 @@ export default {
|
|||||||
throw new GraphQLError('wallet not found', { extensions: { code: 'BAD_INPUT' } })
|
throw new GraphQLError('wallet not found', { extensions: { code: 'BAD_INPUT' } })
|
||||||
}
|
}
|
||||||
|
|
||||||
// determine wallet name for logging
|
|
||||||
let walletName = ''
|
|
||||||
if (wallet.type === 'LND') {
|
|
||||||
walletName = 'walletLND'
|
|
||||||
} else if (wallet.type === 'CLN') {
|
|
||||||
walletName = 'walletCLN'
|
|
||||||
} else if (wallet.type === 'LIGHTNING_ADDRESS') {
|
|
||||||
walletName = 'walletLightningAddress'
|
|
||||||
}
|
|
||||||
|
|
||||||
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: walletName, level: 'SUCCESS', message: 'wallet deleted' } })
|
models.walletLog.create({ data: { userId: me.id, wallet: wallet.type, level: 'SUCCESS', message: 'wallet deleted' } })
|
||||||
])
|
])
|
||||||
|
|
||||||
return true
|
return true
|
||||||
@ -578,7 +567,7 @@ export const addWalletLog = async ({ wallet, level, message }, { me, models }) =
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function upsertWallet (
|
async function upsertWallet (
|
||||||
{ schema, walletName, walletType, testConnect }, { settings, data }, { me, models }) {
|
{ schema, walletType, testConnect }, { 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' } })
|
||||||
}
|
}
|
||||||
@ -591,7 +580,7 @@ async function upsertWallet (
|
|||||||
await testConnect(data)
|
await testConnect(data)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
await addWalletLog({ wallet: walletName, level: 'ERROR', message: 'failed to attach wallet' }, { me, models })
|
await addWalletLog({ wallet: walletType, level: 'ERROR', message: 'failed to attach wallet' }, { me, models })
|
||||||
throw new GraphQLError('failed to connect to wallet', { extensions: { code: 'BAD_INPUT' } })
|
throw new GraphQLError('failed to connect to wallet', { extensions: { code: 'BAD_INPUT' } })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -621,6 +610,9 @@ async function upsertWallet (
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const walletName = walletType === 'LND'
|
||||||
|
? 'walletLND'
|
||||||
|
: walletType === 'CLN' ? 'walletCLN' : 'walletLightningAddress'
|
||||||
if (id) {
|
if (id) {
|
||||||
txs.push(
|
txs.push(
|
||||||
models.wallet.update({
|
models.wallet.update({
|
||||||
@ -635,7 +627,7 @@ async function upsertWallet (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
models.walletLog.create({ data: { userId: me.id, wallet: walletName, level: 'SUCCESS', message: 'wallet updated' } })
|
models.walletLog.create({ data: { userId: me.id, wallet: walletType, level: 'SUCCESS', message: 'wallet updated' } })
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
txs.push(
|
txs.push(
|
||||||
@ -649,7 +641,7 @@ async function upsertWallet (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
models.walletLog.create({ data: { userId: me.id, wallet: walletName, level: 'SUCCESS', message: 'wallet created' } })
|
models.walletLog.create({ data: { userId: me.id, wallet: walletType, level: 'SUCCESS', message: 'wallet created' } })
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -657,7 +649,7 @@ async function upsertWallet (
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createWithdrawal (parent, { invoice, maxFee }, { me, models, lnd, headers, autoWithdraw = false }) {
|
export async function createWithdrawal (parent, { invoice, maxFee }, { me, models, lnd, headers, walletId = null }) {
|
||||||
assertApiKeyNotPermitted({ me })
|
assertApiKeyNotPermitted({ me })
|
||||||
await ssValidate(withdrawlSchema, { invoice, maxFee })
|
await ssValidate(withdrawlSchema, { invoice, maxFee })
|
||||||
await assertGofacYourself({ models, headers })
|
await assertGofacYourself({ models, headers })
|
||||||
@ -696,10 +688,11 @@ export async function createWithdrawal (parent, { invoice, maxFee }, { me, model
|
|||||||
|
|
||||||
const user = await models.user.findUnique({ where: { id: me.id } })
|
const user = await models.user.findUnique({ where: { id: me.id } })
|
||||||
|
|
||||||
|
const autoWithdraw = !!walletId
|
||||||
// create withdrawl transactionally (id, bolt11, amount, fee)
|
// create withdrawl transactionally (id, bolt11, amount, fee)
|
||||||
const [withdrawl] = await serialize(
|
const [withdrawl] = await serialize(
|
||||||
models.$queryRaw`SELECT * FROM create_withdrawl(${decoded.id}, ${invoice},
|
models.$queryRaw`SELECT * FROM create_withdrawl(${decoded.id}, ${invoice},
|
||||||
${Number(decoded.mtokens)}, ${msatsFee}, ${user.name}, ${autoWithdraw})`,
|
${Number(decoded.mtokens)}, ${msatsFee}, ${user.name}, ${autoWithdraw}, ${walletId}::INTEGER)`,
|
||||||
{ models }
|
{ models }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -95,6 +95,7 @@ export default gql`
|
|||||||
id: ID!
|
id: ID!
|
||||||
earnedSats: Int!
|
earnedSats: Int!
|
||||||
sortTime: Date!
|
sortTime: Date!
|
||||||
|
withdrawl: Withdrawl!
|
||||||
}
|
}
|
||||||
|
|
||||||
type Referral {
|
type Referral {
|
||||||
|
@ -53,3 +53,5 @@ benalleng,code review,#1063,202,medium,,,,25k,???,???
|
|||||||
benalleng,pr,#1066,#1060,good-first-issue,,,,20k,???,???
|
benalleng,pr,#1066,#1060,good-first-issue,,,,20k,???,???
|
||||||
benalleng,pr,#1068,#1067,good-first-issue,,,,20k,???,???
|
benalleng,pr,#1068,#1067,good-first-issue,,,,20k,???,???
|
||||||
abhiShandy,helpfulness,#1068,#1067,good-first-issue,,,,2k,abhishandy@stacker.news,2024-04-14
|
abhiShandy,helpfulness,#1068,#1067,good-first-issue,,,,2k,abhishandy@stacker.news,2024-04-14
|
||||||
|
bumi,pr,#1076,,,,,,20k,bumi@getalby.com,2024-04-16
|
||||||
|
benalleng,pr,#1079,#977,easy,,,,100k,???,???
|
||||||
|
|
@ -59,6 +59,9 @@ app.get('/*', async (req, res) => {
|
|||||||
const url = new URL(req.originalUrl, captureUrl)
|
const url = new URL(req.originalUrl, captureUrl)
|
||||||
const timeLabel = `${Date.now()}-${url.href}`
|
const timeLabel = `${Date.now()}-${url.href}`
|
||||||
|
|
||||||
|
const urlParams = new URLSearchParams(url.search)
|
||||||
|
const commentId = urlParams.get('commentId')
|
||||||
|
|
||||||
let page, pages
|
let page, pages
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -87,6 +90,12 @@ app.get('/*', async (req, res) => {
|
|||||||
await page.emulateMediaFeatures([{ name: 'prefers-color-scheme', value: 'dark' }])
|
await page.emulateMediaFeatures([{ name: 'prefers-color-scheme', value: 'dark' }])
|
||||||
await page.goto(url.href, { waitUntil: 'load', timeout })
|
await page.goto(url.href, { waitUntil: 'load', timeout })
|
||||||
console.timeLog(timeLabel, 'page loaded')
|
console.timeLog(timeLabel, 'page loaded')
|
||||||
|
|
||||||
|
if (commentId) {
|
||||||
|
console.timeLog(timeLabel, 'scrolling to comment')
|
||||||
|
await page.waitForSelector('.outline-it')
|
||||||
|
}
|
||||||
|
|
||||||
const file = await page.screenshot({ type: 'png', captureBeyondViewport: false })
|
const file = await page.screenshot({ type: 'png', captureBeyondViewport: false })
|
||||||
console.timeLog(timeLabel, 'screenshot complete')
|
console.timeLog(timeLabel, 'screenshot complete')
|
||||||
res.setHeader('Content-Type', 'image/png')
|
res.setHeader('Content-Type', 'image/png')
|
||||||
|
@ -2,13 +2,17 @@ import { createContext, useContext, useMemo } from 'react'
|
|||||||
import { useQuery } from '@apollo/client'
|
import { useQuery } from '@apollo/client'
|
||||||
import { NORMAL_POLL_INTERVAL, SSR } from '@/lib/constants'
|
import { NORMAL_POLL_INTERVAL, SSR } from '@/lib/constants'
|
||||||
import { BLOCK_HEIGHT } from '@/fragments/blockHeight'
|
import { BLOCK_HEIGHT } from '@/fragments/blockHeight'
|
||||||
|
import { datePivot } from '@/lib/time'
|
||||||
|
|
||||||
export const BlockHeightContext = createContext({
|
export const BlockHeightContext = createContext({
|
||||||
height: 0
|
height: 0,
|
||||||
|
halving: null
|
||||||
})
|
})
|
||||||
|
|
||||||
export const useBlockHeight = () => useContext(BlockHeightContext)
|
export const useBlockHeight = () => useContext(BlockHeightContext)
|
||||||
|
|
||||||
|
const HALVING_INTERVAL = 210000
|
||||||
|
|
||||||
export const BlockHeightProvider = ({ blockHeight, children }) => {
|
export const BlockHeightProvider = ({ blockHeight, children }) => {
|
||||||
const { data } = useQuery(BLOCK_HEIGHT, {
|
const { data } = useQuery(BLOCK_HEIGHT, {
|
||||||
...(SSR
|
...(SSR
|
||||||
@ -18,9 +22,23 @@ export const BlockHeightProvider = ({ blockHeight, children }) => {
|
|||||||
nextFetchPolicy: 'cache-and-network'
|
nextFetchPolicy: 'cache-and-network'
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
const value = useMemo(() => ({
|
const value = useMemo(() => {
|
||||||
height: data?.blockHeight ?? blockHeight ?? 0
|
if (!data?.blockHeight) {
|
||||||
}), [data?.blockHeight, blockHeight])
|
return {
|
||||||
|
height: blockHeight ?? 0,
|
||||||
|
halving: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingBlocks = HALVING_INTERVAL - (data.blockHeight % HALVING_INTERVAL)
|
||||||
|
const minutesUntilHalving = remainingBlocks * 10
|
||||||
|
const halving = datePivot(new Date(), { minutes: minutesUntilHalving })
|
||||||
|
|
||||||
|
return {
|
||||||
|
height: data.blockHeight,
|
||||||
|
halving
|
||||||
|
}
|
||||||
|
}, [data?.blockHeight, blockHeight])
|
||||||
return (
|
return (
|
||||||
<BlockHeightContext.Provider value={value}>
|
<BlockHeightContext.Provider value={value}>
|
||||||
{children}
|
{children}
|
||||||
|
@ -1,18 +1,53 @@
|
|||||||
import Countdown from 'react-countdown'
|
import Countdown from 'react-countdown'
|
||||||
|
|
||||||
export default function SimpleCountdown ({ className, onComplete, date }) {
|
export default function SimpleCountdown (props) {
|
||||||
return (
|
return (
|
||||||
<span className={className}>
|
<CountdownShared
|
||||||
<Countdown
|
{...props} formatter={props => {
|
||||||
date={date}
|
return (
|
||||||
renderer={props => <span className='text-monospace' suppressHydrationWarning> {props.formatted.minutes}:{props.formatted.seconds}</span>}
|
<>
|
||||||
onComplete={onComplete}
|
{props.formatted.minutes}:{props.formatted.seconds}
|
||||||
/>
|
</>
|
||||||
</span>
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LongCountdown ({ className, onComplete, date }) {
|
export function LongCountdown (props) {
|
||||||
|
return (
|
||||||
|
<CountdownShared
|
||||||
|
{...props} formatter={props => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{props.formatted.days && `${props.formatted.days} days `}
|
||||||
|
{props.formatted.hours && `${props.formatted.hours} hours `}
|
||||||
|
{props.formatted.minutes && `${props.formatted.minutes} minutes `}
|
||||||
|
{props.formatted.seconds && `${props.formatted.seconds} seconds `}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CompactLongCountdown (props) {
|
||||||
|
return (
|
||||||
|
<CountdownShared
|
||||||
|
{...props} formatter={props => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{props.formatted.days
|
||||||
|
? ` ${props.formatted.days}d ${props.formatted.hours}h ${props.formatted.minutes}m ${props.formatted.seconds}s`
|
||||||
|
: ` ${props.formatted.hours}:${props.formatted.minutes}:${props.formatted.seconds}`}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CountdownShared ({ className, onComplete, date, formatter }) {
|
||||||
return (
|
return (
|
||||||
<span className={className}>
|
<span className={className}>
|
||||||
<Countdown
|
<Countdown
|
||||||
@ -20,9 +55,7 @@ export function LongCountdown ({ className, onComplete, date }) {
|
|||||||
renderer={props => {
|
renderer={props => {
|
||||||
return (
|
return (
|
||||||
<span suppressHydrationWarning>
|
<span suppressHydrationWarning>
|
||||||
{props.formatted.days && `${props.formatted.days} days `}
|
{formatter(props)}
|
||||||
{props.formatted.minutes && `${props.formatted.minutes} minutes `}
|
|
||||||
{props.formatted.seconds && `${props.formatted.seconds} seconds `}
|
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
|
@ -53,14 +53,21 @@ export function Invoice ({ invoice, modal, onPayment, info, successVerb, webLn }
|
|||||||
description={numWithUnits(invoice.satsRequested, { abbreviate: false })}
|
description={numWithUnits(invoice.satsRequested, { abbreviate: false })}
|
||||||
statusVariant={variant} status={status}
|
statusVariant={variant} status={status}
|
||||||
/>
|
/>
|
||||||
{!invoice.confirmedAt &&
|
{invoice.confirmedAt
|
||||||
<div className='text-muted text-center'>
|
? (
|
||||||
<Countdown
|
<div className='text-muted text-center invisible'>
|
||||||
date={invoice.expiresAt} onComplete={() => {
|
<Countdown date={Date.now()} />
|
||||||
setExpired(true)
|
</div>
|
||||||
}}
|
)
|
||||||
/>
|
: (
|
||||||
</div>}
|
<div className='text-muted text-center'>
|
||||||
|
<Countdown
|
||||||
|
date={invoice.expiresAt} onComplete={() => {
|
||||||
|
setExpired(true)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{!modal &&
|
{!modal &&
|
||||||
<>
|
<>
|
||||||
{info && <div className='text-muted fst-italic text-center'>{info}</div>}
|
{info && <div className='text-muted fst-italic text-center'>{info}</div>}
|
||||||
|
@ -15,10 +15,10 @@ const defaultTips = [100, 1000, 10000, 100000]
|
|||||||
|
|
||||||
const Tips = ({ setOValue }) => {
|
const Tips = ({ setOValue }) => {
|
||||||
const tips = [...getCustomTips(), ...defaultTips].sort((a, b) => a - b)
|
const tips = [...getCustomTips(), ...defaultTips].sort((a, b) => a - b)
|
||||||
return tips.map(num =>
|
return tips.map((num, i) =>
|
||||||
<Button
|
<Button
|
||||||
size='sm'
|
size='sm'
|
||||||
className={`${num > 1 ? 'ms-2' : ''} mb-2`}
|
className={`${i > 0 ? 'ms-2' : ''} mb-2`}
|
||||||
key={num}
|
key={num}
|
||||||
onClick={() => { setOValue(num) }}
|
onClick={() => { setOValue(num) }}
|
||||||
>
|
>
|
||||||
@ -183,7 +183,7 @@ export default function ItemAct ({ onClose, itemId, down, children }) {
|
|||||||
<Tips setOValue={setOValue} />
|
<Tips setOValue={setOValue} />
|
||||||
</div>
|
</div>
|
||||||
{children}
|
{children}
|
||||||
<div className='d-flex'>
|
<div className='d-flex mt-3'>
|
||||||
<SubmitButton variant={down ? 'danger' : 'success'} className='ms-auto mt-1 px-4' value='TIP'>{down && 'down'}zap</SubmitButton>
|
<SubmitButton variant={down ? 'danger' : 'success'} className='ms-auto mt-1 px-4' value='TIP'>{down && 'down'}zap</SubmitButton>
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
|
@ -158,9 +158,17 @@ const initIndexedDB = async (storeName) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const renameWallet = (wallet) => {
|
const renameWallet = (wallet) => {
|
||||||
if (wallet === 'walletLightningAddress') return 'lnAddr'
|
switch (wallet) {
|
||||||
if (wallet === 'walletLND') return 'lnd'
|
case 'walletLightningAddress':
|
||||||
if (wallet === 'walletCLN') return 'cln'
|
case 'LIGHTNING_ADDRESS':
|
||||||
|
return 'lnAddr'
|
||||||
|
case 'walletLND':
|
||||||
|
case 'LND':
|
||||||
|
return 'lnd'
|
||||||
|
case 'walletCLN':
|
||||||
|
case 'CLN':
|
||||||
|
return 'cln'
|
||||||
|
}
|
||||||
return wallet
|
return wallet
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -29,6 +29,7 @@ import { LongCountdown } from './countdown'
|
|||||||
import { nextBillingWithGrace } from '@/lib/territory'
|
import { nextBillingWithGrace } from '@/lib/territory'
|
||||||
import { commentSubTreeRootId } from '@/lib/item'
|
import { commentSubTreeRootId } from '@/lib/item'
|
||||||
import LinkToContext from './link-to-context'
|
import LinkToContext from './link-to-context'
|
||||||
|
import { Badge } from 'react-bootstrap'
|
||||||
|
|
||||||
function Notification ({ n, fresh }) {
|
function Notification ({ n, fresh }) {
|
||||||
const type = n.__typename
|
const type = n.__typename
|
||||||
@ -283,6 +284,7 @@ function WithdrawlPaid ({ n }) {
|
|||||||
<div className='fw-bold text-info ms-2 py-1'>
|
<div className='fw-bold text-info ms-2 py-1'>
|
||||||
<Check className='fill-info me-1' />{numWithUnits(n.earnedSats, { abbreviate: false, unitSingular: 'sat was', unitPlural: 'sats were' })} withdrawn from your account
|
<Check className='fill-info me-1' />{numWithUnits(n.earnedSats, { abbreviate: false, unitSingular: 'sat was', unitPlural: 'sats were' })} withdrawn from your account
|
||||||
<small className='text-muted ms-1 fw-normal' suppressHydrationWarning>{timeSince(new Date(n.sortTime))}</small>
|
<small className='text-muted ms-1 fw-normal' suppressHydrationWarning>{timeSince(new Date(n.sortTime))}</small>
|
||||||
|
{n.withdrawl.autoWithdraw && <Badge className={styles.badge} bg={null}>autowithdraw</Badge>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -26,4 +26,11 @@
|
|||||||
|
|
||||||
.subFormGroup > div {
|
.subFormGroup > div {
|
||||||
margin-right: 0 !important;
|
margin-right: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
color: var(--theme-grey) !important;
|
||||||
|
background: var(--theme-clickToContextColor) !important;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-left: 0.5rem;
|
||||||
}
|
}
|
@ -7,6 +7,7 @@ import { CURRENCY_SYMBOLS } from '@/lib/currency'
|
|||||||
import { NORMAL_POLL_INTERVAL, SSR } from '@/lib/constants'
|
import { NORMAL_POLL_INTERVAL, SSR } from '@/lib/constants'
|
||||||
import { useBlockHeight } from './block-height'
|
import { useBlockHeight } from './block-height'
|
||||||
import { useChainFee } from './chain-fee'
|
import { useChainFee } from './chain-fee'
|
||||||
|
import { CompactLongCountdown } from './countdown'
|
||||||
|
|
||||||
export const PriceContext = React.createContext({
|
export const PriceContext = React.createContext({
|
||||||
price: null,
|
price: null,
|
||||||
@ -50,11 +51,9 @@ export default function Price ({ className }) {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const { price, fiatSymbol } = usePrice()
|
const { price, fiatSymbol } = usePrice()
|
||||||
const { height: blockHeight } = useBlockHeight()
|
const { height: blockHeight, halving } = useBlockHeight()
|
||||||
const { fee: chainFee } = useChainFee()
|
const { fee: chainFee } = useChainFee()
|
||||||
|
|
||||||
if (!price || price < 0 || blockHeight <= 0 || chainFee <= 0) return null
|
|
||||||
|
|
||||||
// Options: yep, 1btc, blockHeight, undefined
|
// Options: yep, 1btc, blockHeight, undefined
|
||||||
// yep -> 1btc -> blockHeight -> chainFee -> undefined -> yep
|
// yep -> 1btc -> blockHeight -> chainFee -> undefined -> yep
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
@ -68,6 +67,9 @@ export default function Price ({ className }) {
|
|||||||
window.localStorage.setItem('asSats', 'chainFee')
|
window.localStorage.setItem('asSats', 'chainFee')
|
||||||
setAsSats('chainFee')
|
setAsSats('chainFee')
|
||||||
} else if (asSats === 'chainFee') {
|
} else if (asSats === 'chainFee') {
|
||||||
|
window.localStorage.setItem('asSats', 'halving')
|
||||||
|
setAsSats('halving')
|
||||||
|
} else if (asSats === 'halving') {
|
||||||
window.localStorage.removeItem('asSats')
|
window.localStorage.removeItem('asSats')
|
||||||
setAsSats('fiat')
|
setAsSats('fiat')
|
||||||
} else {
|
} else {
|
||||||
@ -79,6 +81,7 @@ export default function Price ({ className }) {
|
|||||||
const compClassName = (className || '') + ' text-reset pointer'
|
const compClassName = (className || '') + ' text-reset pointer'
|
||||||
|
|
||||||
if (asSats === 'yep') {
|
if (asSats === 'yep') {
|
||||||
|
if (!price || price < 0) return null
|
||||||
return (
|
return (
|
||||||
<div className={compClassName} onClick={handleClick} variant='link'>
|
<div className={compClassName} onClick={handleClick} variant='link'>
|
||||||
{fixedDecimal(100000000 / price, 0) + ` sats/${fiatSymbol}`}
|
{fixedDecimal(100000000 / price, 0) + ` sats/${fiatSymbol}`}
|
||||||
@ -95,6 +98,7 @@ export default function Price ({ className }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (asSats === 'blockHeight') {
|
if (asSats === 'blockHeight') {
|
||||||
|
if (blockHeight <= 0) return null
|
||||||
return (
|
return (
|
||||||
<div className={compClassName} onClick={handleClick} variant='link'>
|
<div className={compClassName} onClick={handleClick} variant='link'>
|
||||||
{blockHeight}
|
{blockHeight}
|
||||||
@ -102,7 +106,17 @@ export default function Price ({ className }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (asSats === 'halving') {
|
||||||
|
if (!halving) return null
|
||||||
|
return (
|
||||||
|
<div className={compClassName} onClick={handleClick} variant='link'>
|
||||||
|
<CompactLongCountdown date={halving} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (asSats === 'chainFee') {
|
if (asSats === 'chainFee') {
|
||||||
|
if (chainFee <= 0) return null
|
||||||
return (
|
return (
|
||||||
<div className={compClassName} onClick={handleClick} variant='link'>
|
<div className={compClassName} onClick={handleClick} variant='link'>
|
||||||
{chainFee} sat/vB
|
{chainFee} sat/vB
|
||||||
@ -111,6 +125,7 @@ export default function Price ({ className }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (asSats === 'fiat') {
|
if (asSats === 'fiat') {
|
||||||
|
if (!price || price < 0) return null
|
||||||
return (
|
return (
|
||||||
<div className={compClassName} onClick={handleClick} variant='link'>
|
<div className={compClassName} onClick={handleClick} variant='link'>
|
||||||
{fiatSymbol + fixedDecimal(price, 0)}
|
{fiatSymbol + fixedDecimal(price, 0)}
|
||||||
|
@ -3,6 +3,8 @@ import { CopyInput, InputSkeleton } from './form'
|
|||||||
import InvoiceStatus from './invoice-status'
|
import InvoiceStatus from './invoice-status'
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useWebLN } from './webln'
|
import { useWebLN } from './webln'
|
||||||
|
import SimpleCountdown from './countdown'
|
||||||
|
import Bolt11Info from './bolt11-info'
|
||||||
|
|
||||||
export default function Qr ({ asIs, value, webLn, statusVariant, description, status }) {
|
export default function Qr ({ asIs, value, webLn, statusVariant, description, status }) {
|
||||||
const qrValue = asIs ? value : 'lightning:' + value.toUpperCase()
|
const qrValue = asIs ? value : 'lightning:' + value.toUpperCase()
|
||||||
@ -38,15 +40,19 @@ export default function Qr ({ asIs, value, webLn, statusVariant, description, st
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function QrSkeleton ({ status, description }) {
|
export function QrSkeleton ({ status, description, bolt11Info }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className='h-auto mx-auto w-100 clouds' style={{ paddingTop: 'min(300px, 100%)', maxWidth: 'calc(300px)' }} />
|
<div className='h-auto mx-auto w-100 clouds' style={{ paddingTop: 'min(300px, 100%)', maxWidth: 'calc(300px)' }} />
|
||||||
{description && <div className='mt-1 fst-italic text-center text-muted invisible'>.</div>}
|
{description && <div className='mt-1 fst-italic text-center text-muted invisible'>i'm invisible</div>}
|
||||||
<div className='my-3 w-100'>
|
<div className='my-3 w-100'>
|
||||||
<InputSkeleton />
|
<InputSkeleton />
|
||||||
</div>
|
</div>
|
||||||
<InvoiceStatus variant='default' status={status} />
|
<InvoiceStatus variant='default' status={status} />
|
||||||
|
<div className='text-muted text-center invisible'>
|
||||||
|
<SimpleCountdown date={Date.now()} />
|
||||||
|
</div>
|
||||||
|
{bolt11Info && <Bolt11Info />}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -137,6 +137,9 @@ export const NOTIFICATIONS = gql`
|
|||||||
id
|
id
|
||||||
sortTime
|
sortTime
|
||||||
earnedSats
|
earnedSats
|
||||||
|
withdrawl {
|
||||||
|
autoWithdraw
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -23,7 +23,7 @@ export default function FullInvoice () {
|
|||||||
return (
|
return (
|
||||||
<CenterLayout>
|
<CenterLayout>
|
||||||
{error && <div>{error.toString()}</div>}
|
{error && <div>{error.toString()}</div>}
|
||||||
{data ? <Invoice invoice={data.invoice} /> : <QrSkeleton description status='loading' />}
|
{data ? <Invoice invoice={data.invoice} /> : <QrSkeleton description status='loading' bolt11Info />}
|
||||||
</CenterLayout>
|
</CenterLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -7,7 +7,6 @@ import Layout from '@/components/layout'
|
|||||||
import { useMutation, useQuery } from '@apollo/client'
|
import { useMutation, useQuery } from '@apollo/client'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { amountSchema } from '@/lib/validate'
|
import { amountSchema } from '@/lib/validate'
|
||||||
import Countdown from 'react-countdown'
|
|
||||||
import { numWithUnits } from '@/lib/format'
|
import { numWithUnits } from '@/lib/format'
|
||||||
import PageLoading from '@/components/page-loading'
|
import PageLoading from '@/components/page-loading'
|
||||||
import { useShowModal } from '@/components/modal'
|
import { useShowModal } from '@/components/modal'
|
||||||
@ -21,6 +20,7 @@ import { proportions } from '@/lib/madness'
|
|||||||
import { useData } from '@/components/use-data'
|
import { useData } from '@/components/use-data'
|
||||||
import { GrowthPieChartSkeleton } from '@/components/charts-skeletons'
|
import { GrowthPieChartSkeleton } from '@/components/charts-skeletons'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
|
import { CompactLongCountdown } from '@/components/countdown'
|
||||||
|
|
||||||
const GrowthPieChart = dynamic(() => import('@/components/charts').then(mod => mod.GrowthPieChart), {
|
const GrowthPieChart = dynamic(() => import('@/components/charts').then(mod => mod.GrowthPieChart), {
|
||||||
loading: () => <GrowthPieChartSkeleton />
|
loading: () => <GrowthPieChartSkeleton />
|
||||||
@ -77,15 +77,12 @@ export function RewardLine ({ total, time }) {
|
|||||||
{numWithUnits(total)} in rewards
|
{numWithUnits(total)} in rewards
|
||||||
</span>
|
</span>
|
||||||
{time &&
|
{time &&
|
||||||
<Countdown
|
<small style={{ whiteSpace: 'nowrap' }}>
|
||||||
date={time}
|
<CompactLongCountdown
|
||||||
renderer={props =>
|
className='text-monospace'
|
||||||
<small className='text-monospace' suppressHydrationWarning style={{ whiteSpace: 'nowrap' }}>
|
date={time}
|
||||||
{props.formatted.days
|
/>
|
||||||
? ` ${props.formatted.days}d ${props.formatted.hours}h ${props.formatted.minutes}m ${props.formatted.seconds}s`
|
</small>}
|
||||||
: ` ${props.formatted.hours}:${props.formatted.minutes}:${props.formatted.seconds}`}
|
|
||||||
</small>}
|
|
||||||
/>}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -962,7 +962,7 @@ function ApiKeyDeleteObstacle ({ onClose }) {
|
|||||||
const toaster = useToast()
|
const toaster = useToast()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='text-center'>
|
<div className='m-auto' style={{ maxWidth: 'fit-content' }}>
|
||||||
<p className='fw-bold'>
|
<p className='fw-bold'>
|
||||||
Do you really want to delete your API key?
|
Do you really want to delete your API key?
|
||||||
</p>
|
</p>
|
||||||
|
@ -47,6 +47,8 @@ export default function NWC () {
|
|||||||
initialValue={nwcUrl}
|
initialValue={nwcUrl}
|
||||||
label='connection'
|
label='connection'
|
||||||
name='nwcUrl'
|
name='nwcUrl'
|
||||||
|
type='password'
|
||||||
|
autoComplete='new-password'
|
||||||
required
|
required
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
|
@ -128,7 +128,7 @@ export function FundForm () {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
if (called && !error) {
|
if (called && !error) {
|
||||||
return <QrSkeleton description status='generating' />
|
return <QrSkeleton description status='generating' bolt11Info />
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -31,13 +31,16 @@ export default function Withdrawl () {
|
|||||||
export function WithdrawlSkeleton ({ status }) {
|
export function WithdrawlSkeleton ({ status }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className='w-100'>
|
<div className='w-100 form-group'>
|
||||||
<InputSkeleton label='invoice' />
|
<InputSkeleton label='invoice' />
|
||||||
</div>
|
</div>
|
||||||
<div className='w-100'>
|
<div className='w-100 form-group'>
|
||||||
<InputSkeleton label='max fee' />
|
<InputSkeleton label='max fee' />
|
||||||
</div>
|
</div>
|
||||||
<InvoiceStatus status={status} />
|
<InvoiceStatus status={status} />
|
||||||
|
<div className='w-100 mt-3'>
|
||||||
|
<Bolt11Info />
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -108,9 +111,11 @@ function LoadWithdrawl () {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<InvoiceStatus variant={variant} status={status} />
|
<InvoiceStatus variant={variant} status={status} />
|
||||||
<Bolt11Info bolt11={data.withdrawl.bolt11}>
|
<div className='w-100 mt-3'>
|
||||||
<PrivacyOption wd={data.withdrawl} />
|
<Bolt11Info bolt11={data.withdrawl.bolt11}>
|
||||||
</Bolt11Info>
|
<PrivacyOption wd={data.withdrawl} />
|
||||||
|
</Bolt11Info>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,38 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Withdrawl" ADD COLUMN "walletId" INTEGER;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Withdrawl" ADD CONSTRAINT "Withdrawl_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "Wallet"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION create_withdrawl(lnd_id TEXT, invoice TEXT, msats_amount BIGINT, msats_max_fee BIGINT, username TEXT, auto_withdraw BOOLEAN, wallet_id INTEGER)
|
||||||
|
RETURNS "Withdrawl"
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
user_id INTEGER;
|
||||||
|
user_msats BIGINT;
|
||||||
|
withdrawl "Withdrawl";
|
||||||
|
BEGIN
|
||||||
|
PERFORM ASSERT_SERIALIZED();
|
||||||
|
|
||||||
|
SELECT msats, id INTO user_msats, user_id FROM users WHERE name = username;
|
||||||
|
IF (msats_amount + msats_max_fee) > user_msats THEN
|
||||||
|
RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM "Withdrawl" WHERE hash = lnd_id AND status IS NULL) THEN
|
||||||
|
RAISE EXCEPTION 'SN_PENDING_WITHDRAWL_EXISTS';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM "Withdrawl" WHERE hash = lnd_id AND status = 'CONFIRMED') THEN
|
||||||
|
RAISE EXCEPTION 'SN_CONFIRMED_WITHDRAWL_EXISTS';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
INSERT INTO "Withdrawl" (hash, bolt11, "msatsPaying", "msatsFeePaying", "userId", "autoWithdraw", "walletId", created_at, updated_at)
|
||||||
|
VALUES (lnd_id, invoice, msats_amount, msats_max_fee, user_id, auto_withdraw, wallet_id, now_utc(), now_utc()) RETURNING * INTO withdrawl;
|
||||||
|
|
||||||
|
UPDATE users SET msats = msats - msats_amount - msats_max_fee WHERE id = user_id;
|
||||||
|
|
||||||
|
RETURN withdrawl;
|
||||||
|
END;
|
||||||
|
$$;
|
@ -0,0 +1,45 @@
|
|||||||
|
-- exclude bios from spam detection
|
||||||
|
CREATE OR REPLACE FUNCTION item_spam(parent_id INTEGER, user_id INTEGER, within INTERVAL)
|
||||||
|
RETURNS INTEGER
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
repeats INTEGER;
|
||||||
|
self_replies INTEGER;
|
||||||
|
BEGIN
|
||||||
|
-- no fee escalation
|
||||||
|
IF within = interval '0' THEN
|
||||||
|
RETURN 0;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT count(*) INTO repeats
|
||||||
|
FROM "Item"
|
||||||
|
WHERE (
|
||||||
|
(parent_id IS NULL AND "parentId" IS NULL)
|
||||||
|
OR
|
||||||
|
("parentId" = parent_id AND user_id <> (SELECT i."userId" FROM "Item" i WHERE i.id = "Item"."rootId"))
|
||||||
|
)
|
||||||
|
AND "userId" = user_id
|
||||||
|
AND "bio" = 'f'
|
||||||
|
AND created_at > now_utc() - within;
|
||||||
|
|
||||||
|
IF parent_id IS NULL THEN
|
||||||
|
RETURN repeats;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
WITH RECURSIVE base AS (
|
||||||
|
SELECT "Item".id, "Item"."parentId", "Item"."userId"
|
||||||
|
FROM "Item"
|
||||||
|
WHERE id = parent_id
|
||||||
|
AND "userId" = user_id
|
||||||
|
AND created_at > now_utc() - within
|
||||||
|
AND user_id <> (SELECT i."userId" FROM "Item" i WHERE i.id = "Item"."rootId")
|
||||||
|
UNION ALL
|
||||||
|
SELECT "Item".id, "Item"."parentId", "Item"."userId"
|
||||||
|
FROM base p
|
||||||
|
JOIN "Item" ON "Item".id = p."parentId" AND "Item"."userId" = p."userId" AND "Item".created_at > now_utc() - within)
|
||||||
|
SELECT count(*) INTO self_replies FROM base;
|
||||||
|
|
||||||
|
RETURN repeats + self_replies;
|
||||||
|
END;
|
||||||
|
$$;
|
@ -156,6 +156,7 @@ model Wallet {
|
|||||||
walletLightningAddress WalletLightningAddress?
|
walletLightningAddress WalletLightningAddress?
|
||||||
walletLND WalletLND?
|
walletLND WalletLND?
|
||||||
walletCLN WalletCLN?
|
walletCLN WalletCLN?
|
||||||
|
withdrawals Withdrawl[]
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
}
|
}
|
||||||
@ -694,7 +695,9 @@ model Withdrawl {
|
|||||||
msatsFeePaid BigInt?
|
msatsFeePaid BigInt?
|
||||||
status WithdrawlStatus?
|
status WithdrawlStatus?
|
||||||
autoWithdraw Boolean @default(false)
|
autoWithdraw Boolean @default(false)
|
||||||
|
walletId Int?
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
wallet Wallet? @relation(fields: [walletId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
@@index([createdAt], map: "Withdrawl.created_at_index")
|
@@index([createdAt], map: "Withdrawl.created_at_index")
|
||||||
@@index([userId], map: "Withdrawl.userId_index")
|
@@index([userId], map: "Withdrawl.userId_index")
|
||||||
|
19
sndev
19
sndev
@ -62,25 +62,6 @@ docker__stacker_cln() {
|
|||||||
sndev__start() {
|
sndev__start() {
|
||||||
shift
|
shift
|
||||||
|
|
||||||
if ! [ -f .env.development ]; then
|
|
||||||
echo ".env.development does not exist ... creating from .env.sample"
|
|
||||||
cp .env.sample .env.development
|
|
||||||
elif ! git diff --exit-code --diff-algorithm=histogram .env.sample .env.development; then
|
|
||||||
echo ".env.development is different from .env.sample ..."
|
|
||||||
printf "do you want to merge .env.sample into .env.development? [y/N] "
|
|
||||||
read -r answer
|
|
||||||
if [ "$answer" = "y" ]; then
|
|
||||||
# merge .env.sample into .env.development in a posix compliant way
|
|
||||||
git merge-file --theirs .env.development /dev/fd/3 3<<-EOF /dev/fd/4 4<<-EOF
|
|
||||||
$(git show HEAD:.env.sample)
|
|
||||||
EOF
|
|
||||||
$(cat .env.sample)
|
|
||||||
EOF
|
|
||||||
else
|
|
||||||
echo "merge cancelled"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ $# -eq 0 ]; then
|
if [ $# -eq 0 ]; then
|
||||||
docker__compose up --build
|
docker__compose up --build
|
||||||
exit 0
|
exit 0
|
||||||
|
@ -492,10 +492,7 @@ div[contenteditable]:disabled,
|
|||||||
background-color: var(--theme-inputBg);
|
background-color: var(--theme-inputBg);
|
||||||
border-color: var(--theme-borderColor);
|
border-color: var(--theme-borderColor);
|
||||||
}
|
}
|
||||||
.modal-body {
|
|
||||||
align-self: center;
|
|
||||||
width: fit-content;
|
|
||||||
}
|
|
||||||
.modal-body:has(video) {
|
.modal-body:has(video) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { authenticatedLndGrpc, createInvoice } from 'ln-service'
|
import { authenticatedLndGrpc, createInvoice } from 'ln-service'
|
||||||
import { msatsToSats, numWithUnits, satsToMsats } from '@/lib/format'
|
import { msatsToSats, satsToMsats } from '@/lib/format'
|
||||||
import { datePivot } from '@/lib/time'
|
import { datePivot } from '@/lib/time'
|
||||||
import { createWithdrawal, sendToLnAddr, addWalletLog } from '@/api/resolvers/wallet'
|
import { createWithdrawal, sendToLnAddr, addWalletLog } from '@/api/resolvers/wallet'
|
||||||
import { createInvoice as createInvoiceCLN } from '@/lib/cln'
|
import { createInvoice as createInvoiceCLN } from '@/lib/cln'
|
||||||
@ -46,34 +46,18 @@ export async function autoWithdraw ({ data: { id }, models, lnd }) {
|
|||||||
|
|
||||||
for (const wallet of wallets) {
|
for (const wallet of wallets) {
|
||||||
try {
|
try {
|
||||||
const message = `autowithdrawal of ${numWithUnits(amount, { abbreviate: false, unitSingular: 'sat', unitPlural: 'sats' })}`
|
|
||||||
if (wallet.type === 'LND') {
|
if (wallet.type === 'LND') {
|
||||||
await autowithdrawLND(
|
await autowithdrawLND(
|
||||||
{ amount, maxFee },
|
{ amount, maxFee },
|
||||||
{ models, me: user, lnd })
|
{ models, me: user, lnd })
|
||||||
await addWalletLog({
|
|
||||||
wallet: 'walletLND',
|
|
||||||
level: 'SUCCESS',
|
|
||||||
message
|
|
||||||
}, { me: user, models })
|
|
||||||
} else if (wallet.type === 'CLN') {
|
} else if (wallet.type === 'CLN') {
|
||||||
await autowithdrawCLN(
|
await autowithdrawCLN(
|
||||||
{ amount, maxFee },
|
{ amount, maxFee },
|
||||||
{ models, me: user, lnd })
|
{ models, me: user, lnd })
|
||||||
await addWalletLog({
|
|
||||||
wallet: 'walletCLN',
|
|
||||||
level: 'SUCCESS',
|
|
||||||
message
|
|
||||||
}, { me: user, models })
|
|
||||||
} else if (wallet.type === 'LIGHTNING_ADDRESS') {
|
} else if (wallet.type === 'LIGHTNING_ADDRESS') {
|
||||||
await autowithdrawLNAddr(
|
await autowithdrawLNAddr(
|
||||||
{ amount, maxFee },
|
{ amount, maxFee },
|
||||||
{ models, me: user, lnd })
|
{ models, me: user, lnd })
|
||||||
await addWalletLog({
|
|
||||||
wallet: 'walletLightningAddress',
|
|
||||||
level: 'SUCCESS',
|
|
||||||
message
|
|
||||||
}, { me: user, models })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
@ -82,9 +66,7 @@ export async function autoWithdraw ({ data: { id }, models, lnd }) {
|
|||||||
// LND errors are in this shape: [code, type, { err: { code, details, metadata } }]
|
// LND errors are in this shape: [code, type, { err: { code, details, metadata } }]
|
||||||
const details = error[2]?.err?.details || error.message || error.toString?.()
|
const details = error[2]?.err?.details || error.message || error.toString?.()
|
||||||
await addWalletLog({
|
await addWalletLog({
|
||||||
wallet: wallet.type === 'LND'
|
wallet: wallet.type,
|
||||||
? 'walletLND'
|
|
||||||
: wallet.type === 'CLN' ? 'walletCLN' : 'walletLightningAddress',
|
|
||||||
level: 'ERROR',
|
level: 'ERROR',
|
||||||
message: 'autowithdrawal failed: ' + details
|
message: 'autowithdrawal failed: ' + details
|
||||||
}, { me: user, models })
|
}, { me: user, models })
|
||||||
@ -116,7 +98,7 @@ async function autowithdrawLNAddr (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { walletLightningAddress: { address } } = wallet
|
const { walletLightningAddress: { address } } = wallet
|
||||||
return await sendToLnAddr(null, { addr: address, amount, maxFee }, { me, models, lnd, autoWithdraw: true })
|
return await sendToLnAddr(null, { addr: address, amount, maxFee }, { me, models, lnd, walletId: wallet.id })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function autowithdrawLND ({ amount, maxFee }, { me, models, lnd }) {
|
async function autowithdrawLND ({ amount, maxFee }, { me, models, lnd }) {
|
||||||
@ -152,7 +134,7 @@ async function autowithdrawLND ({ amount, maxFee }, { me, models, lnd }) {
|
|||||||
expires_at: datePivot(new Date(), { seconds: 360 })
|
expires_at: datePivot(new Date(), { seconds: 360 })
|
||||||
})
|
})
|
||||||
|
|
||||||
return await createWithdrawal(null, { invoice: invoice.request, maxFee }, { me, models, lnd, autoWithdraw: true })
|
return await createWithdrawal(null, { invoice: invoice.request, maxFee }, { me, models, lnd, walletId: wallet.id })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function autowithdrawCLN ({ amount, maxFee }, { me, models, lnd }) {
|
async function autowithdrawCLN ({ amount, maxFee }, { me, models, lnd }) {
|
||||||
@ -185,5 +167,5 @@ async function autowithdrawCLN ({ amount, maxFee }, { me, models, lnd }) {
|
|||||||
expiry: 360
|
expiry: 360
|
||||||
})
|
})
|
||||||
|
|
||||||
return await createWithdrawal(null, { invoice: inv.bolt11, maxFee }, { me, models, lnd, autoWithdraw: true })
|
return await createWithdrawal(null, { invoice: inv.bolt11, maxFee }, { me, models, lnd, walletId: wallet.id })
|
||||||
}
|
}
|
||||||
|
@ -7,6 +7,8 @@ import { notifyDeposit, notifyWithdrawal } from '@/lib/webPush'
|
|||||||
import { INVOICE_RETENTION_DAYS } from '@/lib/constants'
|
import { INVOICE_RETENTION_DAYS } from '@/lib/constants'
|
||||||
import { datePivot, sleep } from '@/lib/time.js'
|
import { datePivot, sleep } from '@/lib/time.js'
|
||||||
import retry from 'async-retry'
|
import retry from 'async-retry'
|
||||||
|
import { addWalletLog } from '@/api/resolvers/wallet'
|
||||||
|
import { msatsToSats, numWithUnits } from '@/lib/format'
|
||||||
|
|
||||||
export async function subscribeToWallet (args) {
|
export async function subscribeToWallet (args) {
|
||||||
await subscribeToDeposits(args)
|
await subscribeToDeposits(args)
|
||||||
@ -205,7 +207,7 @@ async function subscribeToWithdrawals (args) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function checkWithdrawal ({ data: { hash }, boss, models, lnd }) {
|
async function checkWithdrawal ({ data: { hash }, boss, models, lnd }) {
|
||||||
const dbWdrwl = await models.withdrawl.findFirst({ where: { hash, status: null } })
|
const dbWdrwl = await models.withdrawl.findFirst({ where: { hash, status: null }, include: { wallet: true } })
|
||||||
if (!dbWdrwl) {
|
if (!dbWdrwl) {
|
||||||
// [WARNING] LND paid an invoice that wasn't created via the SN GraphQL API.
|
// [WARNING] LND paid an invoice that wasn't created via the SN GraphQL API.
|
||||||
// >>> an adversary might be draining our funds right now <<<
|
// >>> an adversary might be draining our funds right now <<<
|
||||||
@ -237,16 +239,25 @@ async function checkWithdrawal ({ data: { hash }, boss, models, lnd }) {
|
|||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
notifyWithdrawal(dbWdrwl.userId, wdrwl)
|
notifyWithdrawal(dbWdrwl.userId, wdrwl)
|
||||||
}
|
}
|
||||||
|
if (dbWdrwl.wallet) {
|
||||||
|
// this was an autowithdrawal
|
||||||
|
const message = `autowithdrawal of ${numWithUnits(msatsToSats(paid), { abbreviate: false })} with ${numWithUnits(msatsToSats(fee), { abbreviate: false })} as fee`
|
||||||
|
await addWalletLog({ wallet: dbWdrwl.wallet.type, level: 'SUCCESS', message }, { models, me: { id: dbWdrwl.userId } })
|
||||||
|
}
|
||||||
} else if (wdrwl?.is_failed || notFound) {
|
} else if (wdrwl?.is_failed || notFound) {
|
||||||
let status = 'UNKNOWN_FAILURE'
|
let status = 'UNKNOWN_FAILURE'; let message = 'unknown failure'
|
||||||
if (wdrwl?.failed.is_insufficient_balance) {
|
if (wdrwl?.failed.is_insufficient_balance) {
|
||||||
status = 'INSUFFICIENT_BALANCE'
|
status = 'INSUFFICIENT_BALANCE'
|
||||||
|
message = "you didn't have enough sats"
|
||||||
} else if (wdrwl?.failed.is_invalid_payment) {
|
} else if (wdrwl?.failed.is_invalid_payment) {
|
||||||
status = 'INVALID_PAYMENT'
|
status = 'INVALID_PAYMENT'
|
||||||
|
message = 'invalid payment'
|
||||||
} else if (wdrwl?.failed.is_pathfinding_timeout) {
|
} else if (wdrwl?.failed.is_pathfinding_timeout) {
|
||||||
status = 'PATHFINDING_TIMEOUT'
|
status = 'PATHFINDING_TIMEOUT'
|
||||||
|
message = 'no route found'
|
||||||
} else if (wdrwl?.failed.is_route_not_found) {
|
} else if (wdrwl?.failed.is_route_not_found) {
|
||||||
status = 'ROUTE_NOT_FOUND'
|
status = 'ROUTE_NOT_FOUND'
|
||||||
|
message = 'no route found'
|
||||||
}
|
}
|
||||||
|
|
||||||
await serialize(
|
await serialize(
|
||||||
@ -254,6 +265,15 @@ async function checkWithdrawal ({ data: { hash }, boss, models, lnd }) {
|
|||||||
SELECT reverse_withdrawl(${dbWdrwl.id}::INTEGER, ${status}::"WithdrawlStatus")`,
|
SELECT reverse_withdrawl(${dbWdrwl.id}::INTEGER, ${status}::"WithdrawlStatus")`,
|
||||||
{ models }
|
{ models }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (dbWdrwl.wallet) {
|
||||||
|
// add error into log for autowithdrawal
|
||||||
|
addWalletLog({
|
||||||
|
wallet: dbWdrwl.wallet.type,
|
||||||
|
level: 'ERROR',
|
||||||
|
message: 'autowithdrawal failed: ' + message
|
||||||
|
}, { models, me: { id: dbWdrwl.userId } })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user