autowithdraw to lightning address

This commit is contained in:
keyan 2024-01-07 11:00:24 -06:00
parent efc566c3da
commit 86e8350994
26 changed files with 653 additions and 186 deletions

View File

@ -3,7 +3,7 @@ import { join, resolve } from 'path'
import { GraphQLError } from 'graphql'
import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor'
import { msatsToSats } from '../../lib/format'
import { bioSchema, emailSchema, settingsSchema, ssValidate, userSchema } from '../../lib/validate'
import { bioSchema, emailSchema, lnAddrAutowithdrawSchema, settingsSchema, ssValidate, userSchema } from '../../lib/validate'
import { getItem, updateItem, filterClause, createItem, whereClause, muteClause } from './item'
import { ANON_USER_ID, DELETE_USER_ID, RESERVED_MAX_USER_ID } from '../../lib/constants'
import { viewIntervalClause, intervalClause } from './growth'
@ -517,6 +517,36 @@ export default {
throw error
}
},
setAutoWithdraw: async (parent, data, { me, models }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
}
await ssValidate(lnAddrAutowithdrawSchema, data, { models })
await models.user.update({
where: { id: me.id },
data
})
return true
},
removeAutoWithdraw: async (parent, data, { me, models }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
}
await models.user.update({
where: { id: me.id },
data: {
lnAddr: null,
autoWithdrawThreshold: null,
autoWithdrawMaxFeePercent: null
}
})
return true
},
setSettings: async (parent, { settings: { nostrRelays, ...data } }, { me, models }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })

View File

@ -133,6 +133,7 @@ export default {
'withdrawal' as type,
jsonb_build_object(
'bolt11', bolt11,
'autoWithdraw', "autoWithdraw",
'status', COALESCE(status::text, 'PENDING'),
'msatsFee', COALESCE("msatsFeePaid", "msatsFeePaying")) as other
FROM "Withdrawl"
@ -313,61 +314,7 @@ export default {
}
},
createWithdrawl: createWithdrawal,
sendToLnAddr: async (parent, { addr, amount, maxFee, comment, ...payer }, { me, models, lnd, headers }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
}
const options = await lnAddrOptions(addr)
await ssValidate(lnAddrSchema, { addr, amount, maxFee, comment, ...payer }, options)
if (payer) {
payer = {
...payer,
identifier: payer.identifier ? `${me.name}@stacker.news` : undefined
}
payer = Object.fromEntries(
Object.entries(payer).filter(([, value]) => !!value)
)
}
const milliamount = 1000 * amount
const callback = new URL(options.callback)
callback.searchParams.append('amount', milliamount)
if (comment?.length) {
callback.searchParams.append('comment', comment)
}
let stringifiedPayerData = ''
if (payer && Object.entries(payer).length) {
stringifiedPayerData = JSON.stringify(payer)
callback.searchParams.append('payerdata', stringifiedPayerData)
}
// call callback with amount and conditionally comment
const res = await (await fetch(callback.toString())).json()
if (res.status === 'ERROR') {
throw new Error(res.reason)
}
// decode invoice
try {
const decoded = await decodePaymentRequest({ lnd, request: res.pr })
if (decoded.description_hash !== lnurlPayDescriptionHash(`${options.metadata}${stringifiedPayerData}`)) {
throw new Error('description hash does not match')
}
if (!decoded.mtokens || BigInt(decoded.mtokens) !== BigInt(milliamount)) {
throw new Error('invoice has incorrect amount')
}
} catch (e) {
console.log(e)
throw e
}
// take pr and createWithdrawl
return await createWithdrawal(parent, { invoice: res.pr, maxFee }, { me, models, lnd, headers })
},
sendToLnAddr,
cancelInvoice: async (parent, { hash, hmac }, { models, lnd }) => {
const hmac2 = createHmac(hash)
if (hmac !== hmac2) {
@ -432,7 +379,7 @@ export default {
}
}
export async function createWithdrawal (parent, { invoice, maxFee }, { me, models, lnd, headers }) {
export async function createWithdrawal (parent, { invoice, maxFee }, { me, models, lnd, headers, autoWithdraw = false }) {
await ssValidate(withdrawlSchema, { invoice, maxFee })
await assertGofacYourself({ models, headers })
@ -473,7 +420,7 @@ export async function createWithdrawal (parent, { invoice, maxFee }, { me, model
// create withdrawl transactionally (id, bolt11, amount, fee)
const [withdrawl] = await serialize(models,
models.$queryRaw`SELECT * FROM create_withdrawl(${decoded.id}, ${invoice},
${Number(decoded.mtokens)}, ${msatsFee}, ${user.name})`)
${Number(decoded.mtokens)}, ${msatsFee}, ${user.name}, ${autoWithdraw})`)
payViaPaymentRequest({
lnd,
@ -481,7 +428,64 @@ export async function createWithdrawal (parent, { invoice, maxFee }, { me, model
// can't use max_fee_mtokens https://github.com/alexbosworth/ln-service/issues/141
max_fee: Number(maxFee),
pathfinding_timeout: 30000
})
}).catch(console.error)
return withdrawl
}
export async function sendToLnAddr (parent, { addr, amount, maxFee, comment, ...payer },
{ me, models, lnd, headers, autoWithdraw = false }) {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
}
const options = await lnAddrOptions(addr)
await ssValidate(lnAddrSchema, { addr, amount, maxFee, comment, ...payer }, options)
if (payer) {
payer = {
...payer,
identifier: payer.identifier ? `${me.name}@stacker.news` : undefined
}
payer = Object.fromEntries(
Object.entries(payer).filter(([, value]) => !!value)
)
}
const milliamount = 1000 * amount
const callback = new URL(options.callback)
callback.searchParams.append('amount', milliamount)
if (comment?.length) {
callback.searchParams.append('comment', comment)
}
let stringifiedPayerData = ''
if (payer && Object.entries(payer).length) {
stringifiedPayerData = JSON.stringify(payer)
callback.searchParams.append('payerdata', stringifiedPayerData)
}
// call callback with amount and conditionally comment
const res = await (await fetch(callback.toString())).json()
if (res.status === 'ERROR') {
throw new Error(res.reason)
}
// decode invoice
try {
const decoded = await decodePaymentRequest({ lnd, request: res.pr })
if (decoded.description_hash !== lnurlPayDescriptionHash(`${options.metadata}${stringifiedPayerData}`)) {
throw new Error('description hash does not match')
}
if (!decoded.mtokens || BigInt(decoded.mtokens) !== BigInt(milliamount)) {
throw new Error('invoice has incorrect amount')
}
} catch (e) {
console.log(e)
throw e
}
// take pr and createWithdrawl
return await createWithdrawal(parent, { invoice: res.pr, maxFee }, { me, models, lnd, headers, autoWithdraw })
}

View File

@ -31,6 +31,8 @@ export default gql`
subscribeUserPosts(id: ID): User
subscribeUserComments(id: ID): User
toggleMute(id: ID): User
setAutoWithdraw(lnAddr: String!, autoWithdrawThreshold: Int!, autoWithdrawMaxFeePercent: Float!): Boolean
removeAutoWithdraw: Boolean
}
type User {
@ -98,6 +100,7 @@ export default gql`
"""
sats: Int!
authMethods: AuthMethods!
lnAddr: String
"""
only relevant to user
@ -139,6 +142,8 @@ export default gql`
turboTipping: Boolean!
wildWestMode: Boolean!
withdrawMaxFeeDefault: Int!
autoWithdrawThreshold: Int
autoWithdrawMaxFeePercent: Int
}
type UserOptional {

View File

@ -45,6 +45,7 @@ export default gql`
satsFeePaying: Int!
satsFeePaid: Int
status: String
autoWithdraw: Boolean!
}
type Fact {
@ -55,6 +56,7 @@ export default gql`
bolt11: String
status: String
description: String
autoWithdraw: Boolean
item: Item
invoiceComment: String
invoicePayerData: JSONObject

View File

@ -7,12 +7,9 @@ export default ({ bolt11, preimage, children }) => {
if (bolt11) {
({ tagsObject: { description, payment_hash: paymentHash } } = decode(bolt11))
}
if (!description && !paymentHash && !preimage) {
return null
}
return (
<div className='w-100'>
<div className={`w-100 ${!description && !paymentHash && !preimage ? 'invisible' : ''}`}>
<AccordianItem
header='BOLT11 information'
body={

View File

@ -101,7 +101,7 @@ export default function SubSelect ({ prependSubs, sub, onChange, large, appendSu
size='sm'
{...valueProps}
{...props}
className={`${className} ${styles.subSelect} ${large ? '' : styles.subSelectSmall}`}
className={`${className} ${styles.subSelect} ${large ? 'me-2' : styles.subSelectSmall}`}
items={subs}
/>
)

53
components/wallet-card.js Normal file
View File

@ -0,0 +1,53 @@
import { Badge, Button, Card } from 'react-bootstrap'
import styles from '../styles/wallet.module.css'
import Plug from '../svgs/plug.svg'
import Gear from '../svgs/settings-5-fill.svg'
import Link from 'next/link'
import CancelButton from './cancel-button'
import { SubmitButton } from './form'
export function WalletCard ({ title, badges, provider, enabled }) {
return (
<Card className={styles.card}>
<div className={`${styles.indicator} ${enabled ? styles.success : styles.disabled}`} />
<Card.Body>
<Card.Title>{title}</Card.Title>
<Card.Subtitle className='mt-2'>
{badges?.map(
badge =>
<Badge className={styles.badge} key={badge} bg={null}>
{badge}
</Badge>)}
</Card.Subtitle>
</Card.Body>
{provider &&
<Link href={`/settings/wallets/${provider}`}>
<Card.Footer className={styles.attach}>
{enabled
? <>configure<Gear width={14} height={14} /></>
: <>attach<Plug width={14} height={14} /></>}
</Card.Footer>
</Link>}
</Card>
)
}
export function WalletButtonBar ({
enabled, disable,
className, children, onDelete, onCancel, hasCancel = true,
createText = 'attach', deleteText = 'unattach', editText = 'save'
}) {
return (
<div className={`mt-3 ${className}`}>
<div className='d-flex justify-content-between'>
{enabled &&
<Button onClick={onDelete} variant='grey-medium'>{deleteText}</Button>}
{children}
<div className='d-flex align-items-center ms-auto'>
{hasCancel && <CancelButton onClick={onCancel} />}
<SubmitButton variant='primary' disabled={disable}>{enabled ? editText : createText}</SubmitButton>
</div>
</div>
</div>
)
}

View File

@ -39,6 +39,9 @@ export const ME = gql`
upvotePopover
wildWestMode
withdrawMaxFeeDefault
lnAddr
autoWithdrawMaxFeePercent
autoWithdrawThreshold
}
optional {
isContributor
@ -107,6 +110,20 @@ mutation setSettings($settings: SettingsInput!) {
}
`
export const SET_AUTOWITHDRAW =
gql`
mutation setAutoWithdraw($lnAddr: String!, $autoWithdrawThreshold: Int!, $autoWithdrawMaxFeePercent: Float!) {
setAutoWithdraw(lnAddr: $lnAddr, autoWithdrawThreshold: $autoWithdrawThreshold, autoWithdrawMaxFeePercent: $autoWithdrawMaxFeePercent)
}
`
export const REMOVE_AUTOWITHDRAW =
gql`
mutation removeAutoWithdraw {
removeAutoWithdraw
}
`
export const NAME_QUERY =
gql`
query nameAvailable($name: String!) {

View File

@ -30,6 +30,7 @@ export const WITHDRAWL = gql`
satsFeePaying
satsFeePaid
status
autoWithdraw
}
}`
@ -41,6 +42,7 @@ export const WALLET_HISTORY = gql`
facts {
id
bolt11
autoWithdraw
type
createdAt
sats

View File

@ -40,6 +40,13 @@ export const msatsToSats = msats => {
return Number(BigInt(msats) / 1000n)
}
export const satsToMsats = sats => {
if (sats === null || sats === undefined) {
return null
}
return BigInt(sats) * 1000n
}
export const msatsToSatsDecimal = msats => {
if (msats === null || msats === undefined) {
return null

View File

@ -28,7 +28,8 @@ export function lnurlPayDescriptionHash (data) {
export async function lnAddrOptions (addr) {
await lnAddrSchema().fields.addr.validate(addr)
const [name, domain] = addr.split('@')
const req = await fetch(`https://${domain}/.well-known/lnurlp/${name}`)
const protocol = domain.includes(':') && process.env.NODE_ENV === 'development' ? 'http' : 'https'
const req = await fetch(`${protocol}://${domain}/.well-known/lnurlp/${name}`)
const res = await req.json()
if (res.status === 'ERROR') {
throw new Error(res.reason)

View File

@ -2,12 +2,12 @@ import { string, ValidationError, number, object, array, addMethod, boolean } fr
import {
BOOST_MIN, MAX_POLL_CHOICE_LENGTH, MAX_TITLE_LENGTH, MAX_POLL_NUM_CHOICES,
MIN_POLL_NUM_CHOICES, MAX_FORWARDS, BOOST_MULT, MAX_TERRITORY_DESC_LENGTH, POST_TYPES,
TERRITORY_BILLING_TYPES, MAX_COMMENT_TEXT_LENGTH, MAX_POST_TEXT_LENGTH, MIN_TITLE_LENGTH, BOUNTY_MIN, BOUNTY_MAX
TERRITORY_BILLING_TYPES, MAX_COMMENT_TEXT_LENGTH, MAX_POST_TEXT_LENGTH, MIN_TITLE_LENGTH, BOUNTY_MIN, BOUNTY_MAX, BALANCE_LIMIT_MSATS
} from './constants'
import { URL_REGEXP, WS_REGEXP } from './url'
import { SUPPORTED_CURRENCIES } from './currency'
import { NOSTR_MAX_RELAY_NUM, NOSTR_PUBKEY_BECH32, NOSTR_PUBKEY_HEX } from './nostr'
import { numWithUnits } from './format'
import { msatsToSats, numWithUnits, abbrNum } from './format'
import * as usersFragments from '../fragments/users'
import * as subsFragments from '../fragments/subs'
const { SUB } = subsFragments
@ -59,6 +59,13 @@ const nameValidator = string()
.max(32, 'too long')
const intValidator = number().typeError('must be a number').integer('must be whole')
const floatValidator = number().typeError('must be a number')
const lightningAddressValidator = process.env.NODE_ENV === 'development'
? string().or(
[string().matches(/^[\w_]+@localhost:\d+$/), string().email()],
'address is no good')
: string().email('address is no good')
async function usernameExists (name, { client, models }) {
if (!client && !models) {
@ -197,6 +204,14 @@ export function advSchema (args) {
})
}
export function lnAddrAutowithdrawSchema (args) {
return object({
lnAddr: lightningAddressValidator.required('required'),
autoWithdrawThreshold: intValidator.required('required').min(0, 'must be at least 0').max(msatsToSats(BALANCE_LIMIT_MSATS), `must be at most ${abbrNum(msatsToSats(BALANCE_LIMIT_MSATS))}`),
autoWithdrawMaxFeePercent: floatValidator.required('required').min(0, 'must be at least 0').max(50, 'must not exceed 50')
})
}
export function bountySchema (args) {
return object({
title: titleValidator,
@ -377,7 +392,7 @@ export const withdrawlSchema = object({
export const lnAddrSchema = ({ payerData, min, max, commentAllowed } = {}) =>
object({
addr: string().email('address is no good').required('required'),
addr: lightningAddressValidator.required('required'),
amount: (() => {
const schema = intValidator.required('required').positive('must be positive').min(
min || 1, `must be at least ${min || 1}`)

View File

@ -6,7 +6,7 @@ import serialize from '../../../../api/resolvers/serial'
import { schnorr } from '@noble/curves/secp256k1'
import { createHash } from 'crypto'
import { datePivot } from '../../../../lib/time'
import { BALANCE_LIMIT_MSATS, INV_PENDING_LIMIT, LNURLP_COMMENT_MAX_LENGTH } from '../../../../lib/constants'
import { BALANCE_LIMIT_MSATS, INV_PENDING_LIMIT, LNURLP_COMMENT_MAX_LENGTH, USER_IDS_BALANCE_NO_LIMIT } from '../../../../lib/constants'
import { ssValidate, lud18PayerDataSchema } from '../../../../lib/validate'
import assertGofacYourself from '../../../../api/resolvers/ofac'
@ -83,7 +83,8 @@ export default async ({ query: { username, amount, nostr, comment, payerdata: pa
await serialize(models,
models.$queryRaw`SELECT * FROM create_invoice(${invoice.id}, NULL, ${invoice.request},
${expiresAt}::timestamp, ${Number(amount)}, ${user.id}::INTEGER, ${noteStr || description},
${comment || null}, ${parsedPayerData || null}::JSONB, ${INV_PENDING_LIMIT}::INTEGER, ${BALANCE_LIMIT_MSATS})`)
${comment || null}, ${parsedPayerData || null}::JSONB, ${INV_PENDING_LIMIT}::INTEGER,
${USER_IDS_BALANCE_NO_LIMIT.includes(Number(user.id)) ? 0 : BALANCE_LIMIT_MSATS})`)
return res.status(200).json({
pr: invoice.request,

View File

@ -15,6 +15,7 @@ import { CommentFlat } from '../components/comment'
import ItemJob from '../components/item-job'
import PageLoading from '../components/page-loading'
import PayerData from '../components/payer-data'
import { Badge } from 'react-bootstrap'
export const getServerSideProps = getGetServerSideProps({ query: WALLET_HISTORY, authRequired: true })
@ -78,7 +79,7 @@ function Satus ({ status }) {
}
return (
<span className='d-block'>
<span className='d-inline-block'>
<Icon /><small className={`text-${color} fw-bold ms-2`}>{desc}</small>
</span>
)
@ -132,8 +133,7 @@ function Detail ({ fact }) {
(fact.description && <span className='d-block'>{fact.description}</span>)}
<PayerData data={fact.invoicePayerData} className='text-muted' header />
{fact.invoiceComment && <small className='text-muted'><b>sender says:</b> {fact.invoiceComment}</small>}
{!fact.invoiceComment && !fact.description && fact.bolt11 && <span className='d-block'>no description</span>}
<Satus status={fact.status} />
<Satus status={fact.status} />{fact.autoWithdraw && <Badge className={styles.badge} bg={null}>autowithdraw</Badge>}
</Link>
</div>
)

View File

@ -1,31 +1,31 @@
import { Checkbox, Form, Input, SubmitButton, Select, VariableInput } from '../components/form'
import { Checkbox, Form, Input, SubmitButton, Select, VariableInput } from '../../components/form'
import Alert from 'react-bootstrap/Alert'
import Button from 'react-bootstrap/Button'
import InputGroup from 'react-bootstrap/InputGroup'
import { CenterLayout } from '../components/layout'
import { CenterLayout } from '../../components/layout'
import { useState } from 'react'
import { gql, useMutation, useQuery } from '@apollo/client'
import { getGetServerSideProps } from '../api/ssrApollo'
import LoginButton from '../components/login-button'
import { getGetServerSideProps } from '../../api/ssrApollo'
import LoginButton from '../../components/login-button'
import { signIn } from 'next-auth/react'
import { LightningAuth } from '../components/lightning-auth'
import { SETTINGS, SET_SETTINGS } from '../fragments/users'
import { LightningAuth } from '../../components/lightning-auth'
import { SETTINGS, SET_SETTINGS } from '../../fragments/users'
import { useRouter } from 'next/router'
import Info from '../components/info'
import Info from '../../components/info'
import Link from 'next/link'
import AccordianItem from '../components/accordian-item'
import AccordianItem from '../../components/accordian-item'
import { bech32 } from 'bech32'
import { NOSTR_MAX_RELAY_NUM, NOSTR_PUBKEY_BECH32, DEFAULT_CROSSPOSTING_RELAYS } from '../lib/nostr'
import { emailSchema, lastAuthRemovalSchema, settingsSchema } from '../lib/validate'
import { SUPPORTED_CURRENCIES } from '../lib/currency'
import PageLoading from '../components/page-loading'
import { useShowModal } from '../components/modal'
import { authErrorMessage } from '../components/login'
import { NostrAuth } from '../components/nostr-auth'
import { useToast } from '../components/toast'
import { useLogger } from '../components/logger'
import { useMe } from '../components/me'
import { INVOICE_RETENTION_DAYS } from '../lib/constants'
import { NOSTR_MAX_RELAY_NUM, NOSTR_PUBKEY_BECH32, DEFAULT_CROSSPOSTING_RELAYS } from '../../lib/nostr'
import { emailSchema, lastAuthRemovalSchema, settingsSchema } from '../../lib/validate'
import { SUPPORTED_CURRENCIES } from '../../lib/currency'
import PageLoading from '../../components/page-loading'
import { useShowModal } from '../../components/modal'
import { authErrorMessage } from '../../components/login'
import { NostrAuth } from '../../components/nostr-auth'
import { useToast } from '../../components/toast'
import { useLogger } from '../../components/logger'
import { useMe } from '../../components/me'
import { INVOICE_RETENTION_DAYS } from '../../lib/constants'
export const getServerSideProps = getGetServerSideProps({ query: SETTINGS, authRequired: true })

View File

@ -0,0 +1,24 @@
import { getGetServerSideProps } from '../../../api/ssrApollo'
import Layout from '../../../components/layout'
import styles from '../../../styles/wallet.module.css'
import { WalletCard } from '../../../components/wallet-card'
import { LightningAddressWalletCard } from './lightning-address'
export const getServerSideProps = getGetServerSideProps({ authRequired: true })
export default function Wallet () {
return (
<Layout>
<div className='py-5 w-100'>
<h2 className='mb-2 text-center'>attach wallets</h2>
<h6 className='text-muted text-center'>attach wallets to supplement your SN wallet</h6>
<div className={styles.walletGrid}>
<LightningAddressWalletCard />
<WalletCard title='coming soon' badges={['probably']} />
<WalletCard title='coming soon' badges={['we hope']} />
<WalletCard title='coming soon' badges={['tm']} />
</div>
</div>
</Layout>
)
}

View File

@ -0,0 +1,113 @@
import { InputGroup } from 'react-bootstrap'
import { getGetServerSideProps } from '../../../api/ssrApollo'
import { Form, Input } from '../../../components/form'
import { CenterLayout } from '../../../components/layout'
import { useMe } from '../../../components/me'
import { WalletButtonBar, WalletCard } from '../../../components/wallet-card'
import { useMutation } from '@apollo/client'
import { REMOVE_AUTOWITHDRAW, SET_AUTOWITHDRAW } from '../../../fragments/users'
import { useToast } from '../../../components/toast'
import { lnAddrAutowithdrawSchema } from '../../../lib/validate'
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
export const getServerSideProps = getGetServerSideProps({ authRequired: true })
function useAutoWithdrawEnabled () {
const me = useMe()
return me?.privates?.lnAddr && !isNaN(me?.privates?.autoWithdrawThreshold) && !isNaN(me?.privates?.autoWithdrawMaxFeePercent)
}
export default function LightningAddress () {
const me = useMe()
const toaster = useToast()
const router = useRouter()
const [setAutoWithdraw] = useMutation(SET_AUTOWITHDRAW)
const enabled = useAutoWithdrawEnabled()
const [removeAutoWithdraw] = useMutation(REMOVE_AUTOWITHDRAW)
const autoWithdrawThreshold = isNaN(me?.privates?.autoWithdrawThreshold) ? 10000 : me?.privates?.autoWithdrawThreshold
const [sendThreshold, setSendThreshold] = useState(Math.max(Math.floor(autoWithdrawThreshold / 10), 1))
useEffect(() => {
setSendThreshold(Math.max(Math.floor(me?.privates?.autoWithdrawThreshold / 10), 1))
}, [autoWithdrawThreshold])
return (
<CenterLayout>
<h2 className='pb-2'>lightning address</h2>
<h6 className='text-muted text-center pb-3'>autowithdraw to a lightning address when threshold is breached</h6>
<Form
initial={{
lnAddr: me?.privates?.lnAddr || '',
autoWithdrawThreshold: isNaN(me?.privates?.autoWithdrawThreshold) ? 10000 : me?.privates?.autoWithdrawThreshold,
autoWithdrawMaxFeePercent: isNaN(me?.privantes?.autoWithdrawMaxFeePercent) ? 1 : me?.privantes?.autoWithdrawMaxFeePercent
}}
schema={lnAddrAutowithdrawSchema}
onSubmit={async ({ autoWithdrawThreshold, autoWithdrawMaxFeePercent, ...values }) => {
try {
await setAutoWithdraw({
variables: {
lnAddr: values.lnAddr,
autoWithdrawThreshold: Number(autoWithdrawThreshold),
autoWithdrawMaxFeePercent: Number(autoWithdrawMaxFeePercent)
}
})
toaster.success('saved settings')
router.push('/settings/wallets')
} catch (err) {
console.error(err)
toaster.danger('failed to attach:' + err.message || err.toString?.())
}
}}
>
<Input
label='lightning address'
name='lnAddr'
required
autoFocus
/>
<Input
label='threshold'
name='autoWithdrawThreshold'
onChange={(formik, e) => {
const value = e.target.value
setSendThreshold(Math.max(Math.floor(value / 10), 1))
}}
hint={isNaN(sendThreshold) ? undefined : `note: will attempt withdraw when threshold is exceeded by ${sendThreshold} sats`}
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
/>
<Input
label='max fee'
name='autoWithdrawMaxFeePercent'
hint='max fee as percent of withdrawal amount'
append={<InputGroup.Text>%</InputGroup.Text>}
/>
<WalletButtonBar
enabled={enabled} onDelete={async () => {
try {
await removeAutoWithdraw()
toaster.success('saved settings')
router.push('/settings/wallets')
} catch (err) {
console.error(err)
toaster.danger('failed to unattach:' + err.message || err.toString?.())
}
}}
/>
</Form>
</CenterLayout>
)
}
export function LightningAddressWalletCard () {
const enabled = useAutoWithdrawEnabled()
return (
<WalletCard
title='lightning address'
badges={['receive only', 'non-custodialish']}
provider='lightning-address'
enabled={enabled}
/>
)
}

View File

@ -28,6 +28,7 @@ import { useShowModal } from '../components/modal'
import { useField } from 'formik'
import { useToast } from '../components/toast'
import { WalletLimitBanner } from '../components/banners'
import Plug from '../svgs/plug.svg'
export const getServerSideProps = getGetServerSideProps({ authRequired: true })
@ -84,7 +85,7 @@ function WalletHistory () {
export function WalletForm () {
return (
<div className='align-items-center text-center py-5'>
<div className='align-items-center text-center pt-5 pb-4'>
<Link href='/wallet?type=fund'>
<Button variant='success'>fund</Button>
</Link>
@ -92,6 +93,11 @@ export function WalletForm () {
<Link href='/wallet?type=withdraw'>
<Button variant='success'>withdraw</Button>
</Link>
<div className='mt-5'>
<Link href='/settings/wallets'>
<Button variant='info'>attach wallets <Plug className='fill-white ms-1' width={16} height={16} /></Button>
</Link>
</div>
</div>
)
}

View File

@ -0,0 +1,70 @@
-- AlterTable
ALTER TABLE "Withdrawl" ADD COLUMN "autoWithdraw" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE "users" ADD COLUMN "autoWithdrawMaxFeePercent" DOUBLE PRECISION,
ADD COLUMN "autoWithdrawThreshold" INTEGER,
ADD COLUMN "lnAddr" TEXT;
CREATE OR REPLACE FUNCTION user_auto_withdraw() RETURNS TRIGGER AS $$
DECLARE
BEGIN
INSERT INTO pgboss.job (name, data)
SELECT 'autoWithdraw', jsonb_build_object('id', NEW.id)
-- only if there isn't already a pending job for this user
WHERE NOT EXISTS (
SELECT *
FROM pgboss.job
WHERE name = 'autoWithdraw'
AND data->>'id' = NEW.id::TEXT
AND state = 'created'
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS user_auto_withdraw_trigger ON users;
CREATE TRIGGER user_auto_withdraw_trigger
AFTER UPDATE ON users
FOR EACH ROW
WHEN (
NEW."autoWithdrawThreshold" IS NOT NULL
AND NEW."autoWithdrawMaxFeePercent" IS NOT NULL
AND NEW."lnAddr" IS NOT NULL
-- in excess of at least 10% of the threshold
AND NEW.msats - (NEW."autoWithdrawThreshold" * 1000) >= NEW."autoWithdrawThreshold" * 1000 * 0.1)
EXECUTE PROCEDURE user_auto_withdraw();
DROP FUNCTION IF EXISTS create_withdrawl(TEXT, TEXT, BIGINT, BIGINT, TEXT);
CREATE OR REPLACE FUNCTION create_withdrawl(lnd_id TEXT, invoice TEXT, msats_amount BIGINT, msats_max_fee BIGINT, username TEXT, auto_withdraw BOOLEAN)
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", created_at, updated_at)
VALUES (lnd_id, invoice, msats_amount, msats_max_fee, user_id, auto_withdraw, 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

@ -13,96 +13,99 @@ model Snl {
}
model User {
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
name String? @unique(map: "users.name_unique") @db.Citext
email String? @unique(map: "users.email_unique")
emailVerified DateTime? @map("email_verified")
image String?
msats BigInt @default(0)
freeComments Int @default(5)
freePosts Int @default(2)
checkedNotesAt DateTime?
foundNotesAt DateTime?
pubkey String? @unique(map: "users.pubkey_unique")
tipDefault Int @default(100)
bioId Int?
inviteId String?
tipPopover Boolean @default(false)
upvotePopover Boolean @default(false)
trust Float @default(0)
lastSeenAt DateTime?
stackedMsats BigInt @default(0)
noteAllDescendants Boolean @default(true)
noteTerritoryPosts Boolean @default(true)
noteDeposits Boolean @default(true)
noteEarning Boolean @default(true)
noteInvites Boolean @default(true)
noteItemSats Boolean @default(true)
noteMentions Boolean @default(true)
noteForwardedSats Boolean @default(true)
lastCheckedJobs DateTime?
noteJobIndicator Boolean @default(true)
photoId Int?
upvoteTrust Float @default(0)
hideInvoiceDesc Boolean @default(false)
wildWestMode Boolean @default(false)
greeterMode Boolean @default(false)
fiatCurrency String @default("USD")
withdrawMaxFeeDefault Int @default(10)
autoDropBolt11s Boolean @default(false)
hideFromTopUsers Boolean @default(false)
turboTipping Boolean @default(false)
imgproxyOnly Boolean @default(false)
hideWalletBalance Boolean @default(false)
referrerId Int?
nostrPubkey String?
nostrAuthPubkey String? @unique(map: "users.nostrAuthPubkey_unique")
nostrCrossposting Boolean @default(false)
slashtagId String? @unique(map: "users.slashtagId_unique")
noteCowboyHat Boolean @default(true)
streak Int?
subs String[]
hideCowboyHat Boolean @default(false)
Bookmarks Bookmark[]
Donation Donation[]
Earn Earn[]
invites Invite[] @relation("Invites")
invoices Invoice[]
items Item[] @relation("UserItems")
actions ItemAct[]
mentions Mention[]
messages Message[]
PollVote PollVote[]
PushSubscriptions PushSubscription[]
ReferralAct ReferralAct[]
Streak Streak[]
ThreadSubscriptions ThreadSubscription[]
Upload Upload[] @relation("Uploads")
nostrRelays UserNostrRelay[]
withdrawls Withdrawl[]
bio Item? @relation(fields: [bioId], references: [id])
invite Invite? @relation(fields: [inviteId], references: [id])
photo Upload? @relation(fields: [photoId], references: [id])
referrer User? @relation("referrals", fields: [referrerId], references: [id])
referrees User[] @relation("referrals")
Account Account[]
Session Session[]
itemForwards ItemForward[]
hideBookmarks Boolean @default(false)
followers UserSubscription[] @relation("follower")
followees UserSubscription[] @relation("followee")
hideWelcomeBanner Boolean @default(false)
diagnostics Boolean @default(false)
hideIsContributor Boolean @default(false)
muters Mute[] @relation("muter")
muteds Mute[] @relation("muted")
ArcOut Arc[] @relation("fromUser")
ArcIn Arc[] @relation("toUser")
Sub Sub[]
SubAct SubAct[]
MuteSub MuteSub[]
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
name String? @unique(map: "users.name_unique") @db.Citext
email String? @unique(map: "users.email_unique")
emailVerified DateTime? @map("email_verified")
image String?
msats BigInt @default(0)
freeComments Int @default(5)
freePosts Int @default(2)
checkedNotesAt DateTime?
foundNotesAt DateTime?
pubkey String? @unique(map: "users.pubkey_unique")
tipDefault Int @default(100)
bioId Int?
inviteId String?
tipPopover Boolean @default(false)
upvotePopover Boolean @default(false)
trust Float @default(0)
lastSeenAt DateTime?
stackedMsats BigInt @default(0)
noteAllDescendants Boolean @default(true)
noteTerritoryPosts Boolean @default(true)
noteDeposits Boolean @default(true)
noteEarning Boolean @default(true)
noteInvites Boolean @default(true)
noteItemSats Boolean @default(true)
noteMentions Boolean @default(true)
noteForwardedSats Boolean @default(true)
lastCheckedJobs DateTime?
noteJobIndicator Boolean @default(true)
photoId Int?
upvoteTrust Float @default(0)
hideInvoiceDesc Boolean @default(false)
wildWestMode Boolean @default(false)
greeterMode Boolean @default(false)
fiatCurrency String @default("USD")
withdrawMaxFeeDefault Int @default(10)
autoDropBolt11s Boolean @default(false)
hideFromTopUsers Boolean @default(false)
turboTipping Boolean @default(false)
imgproxyOnly Boolean @default(false)
hideWalletBalance Boolean @default(false)
referrerId Int?
nostrPubkey String?
nostrAuthPubkey String? @unique(map: "users.nostrAuthPubkey_unique")
nostrCrossposting Boolean @default(false)
slashtagId String? @unique(map: "users.slashtagId_unique")
noteCowboyHat Boolean @default(true)
streak Int?
subs String[]
hideCowboyHat Boolean @default(false)
Bookmarks Bookmark[]
Donation Donation[]
Earn Earn[]
invites Invite[] @relation("Invites")
invoices Invoice[]
items Item[] @relation("UserItems")
actions ItemAct[]
mentions Mention[]
messages Message[]
PollVote PollVote[]
PushSubscriptions PushSubscription[]
ReferralAct ReferralAct[]
Streak Streak[]
ThreadSubscriptions ThreadSubscription[]
Upload Upload[] @relation("Uploads")
nostrRelays UserNostrRelay[]
withdrawls Withdrawl[]
bio Item? @relation(fields: [bioId], references: [id])
invite Invite? @relation(fields: [inviteId], references: [id])
photo Upload? @relation(fields: [photoId], references: [id])
referrer User? @relation("referrals", fields: [referrerId], references: [id])
referrees User[] @relation("referrals")
Account Account[]
Session Session[]
itemForwards ItemForward[]
hideBookmarks Boolean @default(false)
followers UserSubscription[] @relation("follower")
followees UserSubscription[] @relation("followee")
hideWelcomeBanner Boolean @default(false)
diagnostics Boolean @default(false)
hideIsContributor Boolean @default(false)
lnAddr String?
autoWithdrawMaxFeePercent Float?
autoWithdrawThreshold Int?
muters Mute[] @relation("muter")
muteds Mute[] @relation("muted")
ArcOut Arc[] @relation("fromUser")
ArcIn Arc[] @relation("toUser")
Sub Sub[]
SubAct SubAct[]
MuteSub MuteSub[]
@@index([photoId])
@@index([createdAt], map: "users.created_at_index")
@ -567,6 +570,7 @@ model Withdrawl {
msatsFeePaying BigInt
msatsFeePaid BigInt?
status WithdrawlStatus?
autoWithdraw Boolean @default(false)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([createdAt], map: "Withdrawl.created_at_index")

View File

@ -144,6 +144,8 @@ $accordion-button-active-icon-dark: $accordion-button-icon;
--theme-brandColor: rgba(0, 0, 0, 0.9);
--theme-grey: #707070;
--theme-link: #007cbe;
--theme-toolbarActive: rgba(0, 0, 0, 0.10);
--theme-toolbarHover: rgba(0, 0, 0, 0.20);
--theme-quoteBar: rgb(206, 208, 212);
--theme-linkHover: #004a72;
--theme-linkVisited: #53758;
@ -753,7 +755,7 @@ div[contenteditable]:focus,
}
.fill-danger {
fill: var(--bs-danger);
fill: var(--bs-danger) !important;
}
.fill-theme-color {

View File

@ -21,6 +21,13 @@
font-family: monospace;
}
.badge {
color: var(--theme-grey) !important;
background: var(--theme-clickToContextColor) !important;
vertical-align: middle;
margin-left: 0.5rem;
}
.detail {
border-bottom: 2px solid var(--theme-clickToContextColor);
padding: .5rem;

57
styles/wallet.module.css Normal file
View File

@ -0,0 +1,57 @@
.walletGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
grid-gap: 20px;
justify-items: center;
align-items: center;
margin-top: 3rem;
margin-left: 20px;
margin-right: 20px;
}
.card {
width: 200px;
height: 150px;
}
.badge {
color: var(--theme-grey) !important;
background: var(--theme-clickToContextColor) !important;
vertical-align: middle;
margin-bottom: 0.1rem;
margin-top: 0.1rem;
margin-right: 0.2rem;
}
.attach {
color: var(--bs-body-color) !important;
text-align: center;
}
.attach svg {
fill: var(--bs-body-color) !important;
margin-left: 0.5rem;
}
.indicator {
width: 10px;
height: 10px;
right: 10px;
top: 10px;
border-radius: 50%;
display: inline-block;
position: absolute;
}
.indicator.success {
color: var(--bs-green) !important;
background-color: var(--bs-green) !important;
border: 1px solid var(--bs-success);
filter: drop-shadow(0 0 2px #20c997);
}
.indicator.disabled {
color: var(--theme-toolbarHover) !important;
background-color: var(--theme-toolbarHover) !important;
border: 1px solid var(--theme-toolbarActive);
}

1
svgs/plug.svg Normal file
View File

@ -0,0 +1 @@
<svg width="512" height="512" viewBox="-64 0 512 512" xmlns="http://www.w3.org/2000/svg"><path d="M320,32a32,32,0,0,0-64,0v96h64Zm48,128H16A16,16,0,0,0,0,176v32a16,16,0,0,0,16,16H32v32A160.07,160.07,0,0,0,160,412.8V512h64V412.8A160.07,160.07,0,0,0,352,256V224h16a16,16,0,0,0,16-16V176A16,16,0,0,0,368,160ZM128,32a32,32,0,0,0-64,0v96h64Z"/></svg>

After

Width:  |  Height:  |  Size: 345 B

View File

@ -1,7 +1,10 @@
import PgBoss from 'pg-boss'
import nextEnv from '@next/env'
import { PrismaClient } from '@prisma/client'
import { autoDropBolt11s, checkPendingDeposits, checkPendingWithdrawals, finalizeHodlInvoice, subscribeToWallet } from './wallet.js'
import {
autoDropBolt11s, checkPendingDeposits, checkPendingWithdrawals,
finalizeHodlInvoice, subscribeToWallet, autoWithdraw
} from './wallet.js'
import { repin } from './repin.js'
import { trust } from './trust.js'
import { auction } from './auction.js'
@ -77,6 +80,7 @@ async function work () {
await boss.work('checkPendingDeposits', jobWrapper(checkPendingDeposits))
await boss.work('checkPendingWithdrawals', jobWrapper(checkPendingWithdrawals))
await boss.work('autoDropBolt11s', jobWrapper(autoDropBolt11s))
await boss.work('autoWithdraw', jobWrapper(autoWithdraw))
await boss.work('repin-*', jobWrapper(repin))
await boss.work('trust', jobWrapper(trust))
await boss.work('timestampItem', jobWrapper(timestampItem))

View File

@ -4,9 +4,10 @@ import {
subscribeToInvoices, subscribeToPayments, subscribeToInvoice
} from 'ln-service'
import { sendUserNotification } from '../api/webPush/index.js'
import { msatsToSats, numWithUnits } from '../lib/format'
import { msatsToSats, numWithUnits, satsToMsats } from '../lib/format'
import { INVOICE_RETENTION_DAYS } from '../lib/constants'
import { sleep } from '../lib/time.js'
import { sendToLnAddr } from '../api/resolvers/wallet.js'
import retry from 'async-retry'
export async function subscribeToWallet (args) {
@ -27,6 +28,7 @@ function subscribeForever (subscribe) {
sub.on('error', reject)
})
} catch (error) {
console.error(error)
throw new Error('error subscribing - trying again')
} finally {
sub?.removeAllListeners()
@ -285,3 +287,46 @@ export async function checkPendingWithdrawals (args) {
}
}
}
export async function autoWithdraw ({ data: { id }, models, lnd }) {
const user = await models.user.findUnique({ where: { id } })
if (!user ||
!user.lnAddr ||
isNaN(user.autoWithdrawThreshold) ||
isNaN(user.autoWithdrawMaxFeePercent)) return
const threshold = satsToMsats(user.autoWithdrawThreshold)
const excess = Number(user.msats - threshold)
// excess must be greater than 10% of threshold
if (excess < Number(threshold) * 0.1) return
const maxFee = msatsToSats(Math.ceil(excess * (user.autoWithdrawMaxFeePercent / 100.0)))
const amount = msatsToSats(excess) - maxFee
// must be >= 1 sat
if (amount < 1) return
// check that
// 1. the user doesn't have an autowithdraw pending
// 2. we have not already attempted to autowithdraw this fee recently
const [pendingOrFailed] = await models.$queryRaw`
SELECT EXISTS(
SELECT *
FROM "Withdrawl"
WHERE "userId" = ${id} AND "autoWithdraw"
AND (status IS NULL
OR (
status <> 'CONFIRMED' AND
now() < created_at + interval '1 hour' AND
"msatsFeePaying" >= ${satsToMsats(maxFee)}
))
)`
if (pendingOrFailed.exists) return
await sendToLnAddr(
null,
{ addr: user.lnAddr, amount, maxFee },
{ models, me: user, lnd, autoWithdraw: true })
}