Compare commits

..

No commits in common. "7d587c7cf88e38c07bcf3d2b51dbe2fc4cbc21a7" and "00ca35465c8b7bd64a6c461ab0ea3762688f5e8d" have entirely different histories.

31 changed files with 176 additions and 365 deletions

View File

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

View File

@ -1,6 +1,6 @@
############################################################################ ############################################################################
# OPTIONAL SECRETS # # AUTH / OPTIONAL #
# put these in .env.local, and don't commit them to git # # if you want to work on logged in features, you'll need some kind of auth #
############################################################################ ############################################################################
# github # github
@ -11,9 +11,16 @@ GITHUB_SECRET=
TWITTER_ID= TWITTER_ID=
TWITTER_SECRET= TWITTER_SECRET=
# email list # email
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=
@ -27,14 +34,9 @@ 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
@ -92,6 +94,10 @@ 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

4
.gitignore vendored
View File

@ -28,9 +28,9 @@ yarn-debug.log*
yarn-error.log* yarn-error.log*
# local env files # local env files
envbak
.env* .env*
!.env.development !.env.sample
!.env.production
# local settings # local settings
.vscode/settings.json .vscode/settings.json

View File

@ -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, getWithdrawl } from './wallet' import { getInvoice } 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,9 +444,6 @@ 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({

View File

@ -55,31 +55,6 @@ 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')
@ -124,7 +99,30 @@ export default {
} }
}) })
}, },
withdrawl: getWithdrawl, withdrawl: async (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
},
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' } })
@ -437,11 +435,12 @@ export default {
data.macaroon = ensureB64(data.macaroon) data.macaroon = ensureB64(data.macaroon)
data.cert = ensureB64(data.cert) data.cert = ensureB64(data.cert)
const walletType = 'LND' const wallet = 'walletLND'
return await upsertWallet( return await upsertWallet(
{ {
schema: LNDAutowithdrawSchema, schema: LNDAutowithdrawSchema,
walletType, walletName: wallet,
walletType: 'LND',
testConnect: async ({ cert, macaroon, socket }) => { testConnect: async ({ cert, macaroon, socket }) => {
try { try {
const { lnd } = await authenticatedLndGrpc({ const { lnd } = await authenticatedLndGrpc({
@ -456,12 +455,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: walletType, level: 'SUCCESS', message: 'connected to LND' }, { me, models }) await addWalletLog({ wallet, 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: walletType, level: 'ERROR', message: `could not connect to LND: ${details}` }, { me, models }) await addWalletLog({ wallet, level: 'ERROR', message: `could not connect to LND: ${details}` }, { me, models })
throw err throw err
} }
} }
@ -471,11 +470,12 @@ 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 walletType = 'CLN' const wallet = 'walletCLN'
return await upsertWallet( return await upsertWallet(
{ {
schema: CLNAutowithdrawSchema, schema: CLNAutowithdrawSchema,
walletType, walletName: wallet,
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: walletType, level: 'SUCCESS', message: 'connected to CLN' }, { me, models }) await addWalletLog({ wallet, 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: walletType, level: 'ERROR', message: `could not connect to CLN: ${details}` }, { me, models }) await addWalletLog({ wallet, level: 'ERROR', message: `could not connect to CLN: ${details}` }, { me, models })
throw err throw err
} }
} }
@ -498,14 +498,15 @@ 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 walletType = 'LIGHTNING_ADDRESS' const wallet = 'walletLightningAddress'
return await upsertWallet( return await upsertWallet(
{ {
schema: lnAddrAutowithdrawSchema, schema: lnAddrAutowithdrawSchema,
walletType, walletName: wallet,
walletType: 'LIGHTNING_ADDRESS',
testConnect: async ({ address }) => { testConnect: async ({ address }) => {
const options = await lnAddrOptions(address) const options = await lnAddrOptions(address)
await addWalletLog({ wallet: walletType, level: 'SUCCESS', message: 'fetched payment details' }, { me, models }) await addWalletLog({ wallet, level: 'SUCCESS', message: 'fetched payment details' }, { me, models })
return options return options
} }
}, },
@ -521,9 +522,19 @@ 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: wallet.type, level: 'SUCCESS', message: 'wallet deleted' } }) models.walletLog.create({ data: { userId: me.id, wallet: walletName, level: 'SUCCESS', message: 'wallet deleted' } })
]) ])
return true return true
@ -567,7 +578,7 @@ export const addWalletLog = async ({ wallet, level, message }, { me, models }) =
} }
async function upsertWallet ( async function upsertWallet (
{ schema, walletType, testConnect }, { settings, data }, { me, models }) { { schema, walletName, 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' } })
} }
@ -580,7 +591,7 @@ async function upsertWallet (
await testConnect(data) await testConnect(data)
} catch (err) { } catch (err) {
console.error(err) console.error(err)
await addWalletLog({ wallet: walletType, level: 'ERROR', message: 'failed to attach wallet' }, { me, models }) await addWalletLog({ wallet: walletName, 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' } })
} }
} }
@ -610,9 +621,6 @@ 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({
@ -627,7 +635,7 @@ async function upsertWallet (
} }
} }
}), }),
models.walletLog.create({ data: { userId: me.id, wallet: walletType, level: 'SUCCESS', message: 'wallet updated' } }) models.walletLog.create({ data: { userId: me.id, wallet: walletName, level: 'SUCCESS', message: 'wallet updated' } })
) )
} else { } else {
txs.push( txs.push(
@ -641,7 +649,7 @@ async function upsertWallet (
} }
} }
}), }),
models.walletLog.create({ data: { userId: me.id, wallet: walletType, level: 'SUCCESS', message: 'wallet created' } }) models.walletLog.create({ data: { userId: me.id, wallet: walletName, level: 'SUCCESS', message: 'wallet created' } })
) )
} }
@ -649,7 +657,7 @@ async function upsertWallet (
return true return true
} }
export async function createWithdrawal (parent, { invoice, maxFee }, { me, models, lnd, headers, walletId = null }) { export async function createWithdrawal (parent, { invoice, maxFee }, { me, models, lnd, headers, autoWithdraw = false }) {
assertApiKeyNotPermitted({ me }) assertApiKeyNotPermitted({ me })
await ssValidate(withdrawlSchema, { invoice, maxFee }) await ssValidate(withdrawlSchema, { invoice, maxFee })
await assertGofacYourself({ models, headers }) await assertGofacYourself({ models, headers })
@ -688,11 +696,10 @@ 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}, ${walletId}::INTEGER)`, ${Number(decoded.mtokens)}, ${msatsFee}, ${user.name}, ${autoWithdraw})`,
{ models } { models }
) )

View File

@ -95,7 +95,6 @@ export default gql`
id: ID! id: ID!
earnedSats: Int! earnedSats: Int!
sortTime: Date! sortTime: Date!
withdrawl: Withdrawl!
} }
type Referral { type Referral {

View File

@ -53,5 +53,3 @@ 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,???,???

1 name type pr id issue ids difficulty priority changes requested notes amount receive method date paid
53 benalleng pr #1066 #1060 good-first-issue 20k ??? ???
54 benalleng pr #1068 #1067 good-first-issue 20k ??? ???
55 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 ??? ???

View File

@ -59,9 +59,6 @@ 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 {
@ -90,12 +87,6 @@ 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')

View File

@ -2,17 +2,13 @@ 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
@ -22,23 +18,9 @@ export const BlockHeightProvider = ({ blockHeight, children }) => {
nextFetchPolicy: 'cache-and-network' nextFetchPolicy: 'cache-and-network'
}) })
}) })
const value = useMemo(() => { const value = useMemo(() => ({
if (!data?.blockHeight) { height: data?.blockHeight ?? blockHeight ?? 0
return { }), [data?.blockHeight, blockHeight])
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}

View File

@ -1,53 +1,18 @@
import Countdown from 'react-countdown' import Countdown from 'react-countdown'
export default function SimpleCountdown (props) { export default function SimpleCountdown ({ className, onComplete, date }) {
return ( return (
<CountdownShared <span className={className}>
{...props} formatter={props => { <Countdown
return ( date={date}
<> renderer={props => <span className='text-monospace' suppressHydrationWarning> {props.formatted.minutes}:{props.formatted.seconds}</span>}
{props.formatted.minutes}:{props.formatted.seconds} onComplete={onComplete}
</>
)
}}
/> />
</span>
) )
} }
export function LongCountdown (props) { export function LongCountdown ({ className, onComplete, date }) {
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
@ -55,7 +20,9 @@ function CountdownShared ({ className, onComplete, date, formatter }) {
renderer={props => { renderer={props => {
return ( return (
<span suppressHydrationWarning> <span suppressHydrationWarning>
{formatter(props)} {props.formatted.days && `${props.formatted.days} days `}
{props.formatted.minutes && `${props.formatted.minutes} minutes `}
{props.formatted.seconds && `${props.formatted.seconds} seconds `}
</span> </span>
) )
}} }}

View File

@ -53,21 +53,14 @@ 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 invisible'>
<Countdown date={Date.now()} />
</div>
)
: (
<div className='text-muted text-center'> <div className='text-muted text-center'>
<Countdown <Countdown
date={invoice.expiresAt} onComplete={() => { date={invoice.expiresAt} onComplete={() => {
setExpired(true) setExpired(true)
}} }}
/> />
</div> </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>}

View File

@ -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, i) => return tips.map(num =>
<Button <Button
size='sm' size='sm'
className={`${i > 0 ? 'ms-2' : ''} mb-2`} className={`${num > 1 ? '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 mt-3'> <div className='d-flex'>
<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>

View File

@ -158,17 +158,9 @@ const initIndexedDB = async (storeName) => {
} }
const renameWallet = (wallet) => { const renameWallet = (wallet) => {
switch (wallet) { if (wallet === 'walletLightningAddress') return 'lnAddr'
case 'walletLightningAddress': if (wallet === 'walletLND') return 'lnd'
case 'LIGHTNING_ADDRESS': if (wallet === 'walletCLN') return 'cln'
return 'lnAddr'
case 'walletLND':
case 'LND':
return 'lnd'
case 'walletCLN':
case 'CLN':
return 'cln'
}
return wallet return wallet
} }

View File

@ -29,7 +29,6 @@ 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
@ -284,7 +283,6 @@ 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>
) )
} }

View File

@ -27,10 +27,3 @@
.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;
}

View File

@ -7,7 +7,6 @@ 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,
@ -51,9 +50,11 @@ export default function Price ({ className }) {
}, []) }, [])
const { price, fiatSymbol } = usePrice() const { price, fiatSymbol } = usePrice()
const { height: blockHeight, halving } = useBlockHeight() const { height: blockHeight } = 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 = () => {
@ -67,9 +68,6 @@ 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 {
@ -81,7 +79,6 @@ 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}`}
@ -98,7 +95,6 @@ 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}
@ -106,17 +102,7 @@ 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
@ -125,7 +111,6 @@ 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)}

View File

@ -3,8 +3,6 @@ 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()
@ -40,19 +38,15 @@ export default function Qr ({ asIs, value, webLn, statusVariant, description, st
) )
} }
export function QrSkeleton ({ status, description, bolt11Info }) { export function QrSkeleton ({ status, description }) {
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'>i'm invisible</div>} {description && <div className='mt-1 fst-italic text-center text-muted 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 />}
</> </>
) )
} }

View File

@ -137,9 +137,6 @@ export const NOTIFICATIONS = gql`
id id
sortTime sortTime
earnedSats earnedSats
withdrawl {
autoWithdraw
}
} }
} }
} }

View File

@ -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' bolt11Info />} {data ? <Invoice invoice={data.invoice} /> : <QrSkeleton description status='loading' />}
</CenterLayout> </CenterLayout>
) )
} }

View File

@ -7,6 +7,7 @@ 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'
@ -20,7 +21,6 @@ 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,12 +77,15 @@ export function RewardLine ({ total, time }) {
{numWithUnits(total)} in rewards {numWithUnits(total)} in rewards
</span> </span>
{time && {time &&
<small style={{ whiteSpace: 'nowrap' }}> <Countdown
<CompactLongCountdown
className='text-monospace'
date={time} date={time}
/> renderer={props =>
<small className='text-monospace' suppressHydrationWarning style={{ whiteSpace: 'nowrap' }}>
{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}`}
</small>} </small>}
/>}
</> </>
) )
} }

View File

@ -962,7 +962,7 @@ function ApiKeyDeleteObstacle ({ onClose }) {
const toaster = useToast() const toaster = useToast()
return ( return (
<div className='m-auto' style={{ maxWidth: 'fit-content' }}> <div className='text-center'>
<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>

View File

@ -47,8 +47,6 @@ 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
/> />

View File

@ -128,7 +128,7 @@ export function FundForm () {
}, []) }, [])
if (called && !error) { if (called && !error) {
return <QrSkeleton description status='generating' bolt11Info /> return <QrSkeleton description status='generating' />
} }
return ( return (

View File

@ -31,16 +31,13 @@ export default function Withdrawl () {
export function WithdrawlSkeleton ({ status }) { export function WithdrawlSkeleton ({ status }) {
return ( return (
<> <>
<div className='w-100 form-group'> <div className='w-100'>
<InputSkeleton label='invoice' /> <InputSkeleton label='invoice' />
</div> </div>
<div className='w-100 form-group'> <div className='w-100'>
<InputSkeleton label='max fee' /> <InputSkeleton label='max fee' />
</div> </div>
<InvoiceStatus status={status} /> <InvoiceStatus status={status} />
<div className='w-100 mt-3'>
<Bolt11Info />
</div>
</> </>
) )
} }
@ -111,11 +108,9 @@ function LoadWithdrawl () {
/> />
</div> </div>
<InvoiceStatus variant={variant} status={status} /> <InvoiceStatus variant={variant} status={status} />
<div className='w-100 mt-3'>
<Bolt11Info bolt11={data.withdrawl.bolt11}> <Bolt11Info bolt11={data.withdrawl.bolt11}>
<PrivacyOption wd={data.withdrawl} /> <PrivacyOption wd={data.withdrawl} />
</Bolt11Info> </Bolt11Info>
</div>
</> </>
) )
} }

View File

@ -1,38 +0,0 @@
-- 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;
$$;

View File

@ -1,45 +0,0 @@
-- 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;
$$;

View File

@ -156,7 +156,6 @@ model Wallet {
walletLightningAddress WalletLightningAddress? walletLightningAddress WalletLightningAddress?
walletLND WalletLND? walletLND WalletLND?
walletCLN WalletCLN? walletCLN WalletCLN?
withdrawals Withdrawl[]
@@index([userId]) @@index([userId])
} }
@ -695,9 +694,7 @@ 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
View File

@ -62,6 +62,25 @@ 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

View File

@ -492,7 +492,10 @@ 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%;
} }

View File

@ -1,5 +1,5 @@
import { authenticatedLndGrpc, createInvoice } from 'ln-service' import { authenticatedLndGrpc, createInvoice } from 'ln-service'
import { msatsToSats, satsToMsats } from '@/lib/format' import { msatsToSats, numWithUnits, 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,18 +46,34 @@ 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
@ -66,7 +82,9 @@ 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, wallet: wallet.type === 'LND'
? 'walletLND'
: wallet.type === 'CLN' ? 'walletCLN' : 'walletLightningAddress',
level: 'ERROR', level: 'ERROR',
message: 'autowithdrawal failed: ' + details message: 'autowithdrawal failed: ' + details
}, { me: user, models }) }, { me: user, models })
@ -98,7 +116,7 @@ async function autowithdrawLNAddr (
} }
const { walletLightningAddress: { address } } = wallet const { walletLightningAddress: { address } } = wallet
return await sendToLnAddr(null, { addr: address, amount, maxFee }, { me, models, lnd, walletId: wallet.id }) return await sendToLnAddr(null, { addr: address, amount, maxFee }, { me, models, lnd, autoWithdraw: true })
} }
async function autowithdrawLND ({ amount, maxFee }, { me, models, lnd }) { async function autowithdrawLND ({ amount, maxFee }, { me, models, lnd }) {
@ -134,7 +152,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, walletId: wallet.id }) return await createWithdrawal(null, { invoice: invoice.request, maxFee }, { me, models, lnd, autoWithdraw: true })
} }
async function autowithdrawCLN ({ amount, maxFee }, { me, models, lnd }) { async function autowithdrawCLN ({ amount, maxFee }, { me, models, lnd }) {
@ -167,5 +185,5 @@ async function autowithdrawCLN ({ amount, maxFee }, { me, models, lnd }) {
expiry: 360 expiry: 360
}) })
return await createWithdrawal(null, { invoice: inv.bolt11, maxFee }, { me, models, lnd, walletId: wallet.id }) return await createWithdrawal(null, { invoice: inv.bolt11, maxFee }, { me, models, lnd, autoWithdraw: true })
} }

View File

@ -7,8 +7,6 @@ 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)
@ -207,7 +205,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 }, include: { wallet: true } }) const dbWdrwl = await models.withdrawl.findFirst({ where: { hash, status: null } })
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 <<<
@ -239,25 +237,16 @@ 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 message = 'unknown failure' let status = '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(
@ -265,15 +254,6 @@ 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 } })
}
} }
} }