autowithdraw to lightning address
This commit is contained in:
parent
efc566c3da
commit
86e8350994
|
@ -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' } })
|
||||
|
|
|
@ -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 })
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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={
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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!) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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}`)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
|
|
@ -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 })
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
$$;
|
|
@ -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")
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -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 |
|
@ -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))
|
||||
|
|
|
@ -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 })
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue