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 { GraphQLError } from 'graphql'
|
||||||
import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor'
|
import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor'
|
||||||
import { msatsToSats } from '../../lib/format'
|
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 { getItem, updateItem, filterClause, createItem, whereClause, muteClause } from './item'
|
||||||
import { ANON_USER_ID, DELETE_USER_ID, RESERVED_MAX_USER_ID } from '../../lib/constants'
|
import { ANON_USER_ID, DELETE_USER_ID, RESERVED_MAX_USER_ID } from '../../lib/constants'
|
||||||
import { viewIntervalClause, intervalClause } from './growth'
|
import { viewIntervalClause, intervalClause } from './growth'
|
||||||
@ -517,6 +517,36 @@ export default {
|
|||||||
throw error
|
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 }) => {
|
setSettings: async (parent, { settings: { nostrRelays, ...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' } })
|
||||||
|
@ -133,6 +133,7 @@ export default {
|
|||||||
'withdrawal' as type,
|
'withdrawal' as type,
|
||||||
jsonb_build_object(
|
jsonb_build_object(
|
||||||
'bolt11', bolt11,
|
'bolt11', bolt11,
|
||||||
|
'autoWithdraw', "autoWithdraw",
|
||||||
'status', COALESCE(status::text, 'PENDING'),
|
'status', COALESCE(status::text, 'PENDING'),
|
||||||
'msatsFee', COALESCE("msatsFeePaid", "msatsFeePaying")) as other
|
'msatsFee', COALESCE("msatsFeePaid", "msatsFeePaying")) as other
|
||||||
FROM "Withdrawl"
|
FROM "Withdrawl"
|
||||||
@ -313,61 +314,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
createWithdrawl: createWithdrawal,
|
createWithdrawl: createWithdrawal,
|
||||||
sendToLnAddr: async (parent, { addr, amount, maxFee, comment, ...payer }, { me, models, lnd, headers }) => {
|
sendToLnAddr,
|
||||||
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 })
|
|
||||||
},
|
|
||||||
cancelInvoice: async (parent, { hash, hmac }, { models, lnd }) => {
|
cancelInvoice: async (parent, { hash, hmac }, { models, lnd }) => {
|
||||||
const hmac2 = createHmac(hash)
|
const hmac2 = createHmac(hash)
|
||||||
if (hmac !== hmac2) {
|
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 ssValidate(withdrawlSchema, { invoice, maxFee })
|
||||||
await assertGofacYourself({ models, headers })
|
await assertGofacYourself({ models, headers })
|
||||||
|
|
||||||
@ -473,7 +420,7 @@ export async function createWithdrawal (parent, { invoice, maxFee }, { me, model
|
|||||||
// create withdrawl transactionally (id, bolt11, amount, fee)
|
// create withdrawl transactionally (id, bolt11, amount, fee)
|
||||||
const [withdrawl] = await serialize(models,
|
const [withdrawl] = await serialize(models,
|
||||||
models.$queryRaw`SELECT * FROM create_withdrawl(${decoded.id}, ${invoice},
|
models.$queryRaw`SELECT * FROM create_withdrawl(${decoded.id}, ${invoice},
|
||||||
${Number(decoded.mtokens)}, ${msatsFee}, ${user.name})`)
|
${Number(decoded.mtokens)}, ${msatsFee}, ${user.name}, ${autoWithdraw})`)
|
||||||
|
|
||||||
payViaPaymentRequest({
|
payViaPaymentRequest({
|
||||||
lnd,
|
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
|
// can't use max_fee_mtokens https://github.com/alexbosworth/ln-service/issues/141
|
||||||
max_fee: Number(maxFee),
|
max_fee: Number(maxFee),
|
||||||
pathfinding_timeout: 30000
|
pathfinding_timeout: 30000
|
||||||
})
|
}).catch(console.error)
|
||||||
|
|
||||||
return withdrawl
|
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
|
subscribeUserPosts(id: ID): User
|
||||||
subscribeUserComments(id: ID): User
|
subscribeUserComments(id: ID): User
|
||||||
toggleMute(id: ID): User
|
toggleMute(id: ID): User
|
||||||
|
setAutoWithdraw(lnAddr: String!, autoWithdrawThreshold: Int!, autoWithdrawMaxFeePercent: Float!): Boolean
|
||||||
|
removeAutoWithdraw: Boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type User {
|
type User {
|
||||||
@ -98,6 +100,7 @@ export default gql`
|
|||||||
"""
|
"""
|
||||||
sats: Int!
|
sats: Int!
|
||||||
authMethods: AuthMethods!
|
authMethods: AuthMethods!
|
||||||
|
lnAddr: String
|
||||||
|
|
||||||
"""
|
"""
|
||||||
only relevant to user
|
only relevant to user
|
||||||
@ -139,6 +142,8 @@ export default gql`
|
|||||||
turboTipping: Boolean!
|
turboTipping: Boolean!
|
||||||
wildWestMode: Boolean!
|
wildWestMode: Boolean!
|
||||||
withdrawMaxFeeDefault: Int!
|
withdrawMaxFeeDefault: Int!
|
||||||
|
autoWithdrawThreshold: Int
|
||||||
|
autoWithdrawMaxFeePercent: Int
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserOptional {
|
type UserOptional {
|
||||||
|
@ -45,6 +45,7 @@ export default gql`
|
|||||||
satsFeePaying: Int!
|
satsFeePaying: Int!
|
||||||
satsFeePaid: Int
|
satsFeePaid: Int
|
||||||
status: String
|
status: String
|
||||||
|
autoWithdraw: Boolean!
|
||||||
}
|
}
|
||||||
|
|
||||||
type Fact {
|
type Fact {
|
||||||
@ -55,6 +56,7 @@ export default gql`
|
|||||||
bolt11: String
|
bolt11: String
|
||||||
status: String
|
status: String
|
||||||
description: String
|
description: String
|
||||||
|
autoWithdraw: Boolean
|
||||||
item: Item
|
item: Item
|
||||||
invoiceComment: String
|
invoiceComment: String
|
||||||
invoicePayerData: JSONObject
|
invoicePayerData: JSONObject
|
||||||
|
@ -7,12 +7,9 @@ export default ({ bolt11, preimage, children }) => {
|
|||||||
if (bolt11) {
|
if (bolt11) {
|
||||||
({ tagsObject: { description, payment_hash: paymentHash } } = decode(bolt11))
|
({ tagsObject: { description, payment_hash: paymentHash } } = decode(bolt11))
|
||||||
}
|
}
|
||||||
if (!description && !paymentHash && !preimage) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='w-100'>
|
<div className={`w-100 ${!description && !paymentHash && !preimage ? 'invisible' : ''}`}>
|
||||||
<AccordianItem
|
<AccordianItem
|
||||||
header='BOLT11 information'
|
header='BOLT11 information'
|
||||||
body={
|
body={
|
||||||
|
@ -101,7 +101,7 @@ export default function SubSelect ({ prependSubs, sub, onChange, large, appendSu
|
|||||||
size='sm'
|
size='sm'
|
||||||
{...valueProps}
|
{...valueProps}
|
||||||
{...props}
|
{...props}
|
||||||
className={`${className} ${styles.subSelect} ${large ? '' : styles.subSelectSmall}`}
|
className={`${className} ${styles.subSelect} ${large ? 'me-2' : styles.subSelectSmall}`}
|
||||||
items={subs}
|
items={subs}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
53
components/wallet-card.js
Normal file
53
components/wallet-card.js
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
@ -39,6 +39,9 @@ export const ME = gql`
|
|||||||
upvotePopover
|
upvotePopover
|
||||||
wildWestMode
|
wildWestMode
|
||||||
withdrawMaxFeeDefault
|
withdrawMaxFeeDefault
|
||||||
|
lnAddr
|
||||||
|
autoWithdrawMaxFeePercent
|
||||||
|
autoWithdrawThreshold
|
||||||
}
|
}
|
||||||
optional {
|
optional {
|
||||||
isContributor
|
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 =
|
export const NAME_QUERY =
|
||||||
gql`
|
gql`
|
||||||
query nameAvailable($name: String!) {
|
query nameAvailable($name: String!) {
|
||||||
|
@ -30,6 +30,7 @@ export const WITHDRAWL = gql`
|
|||||||
satsFeePaying
|
satsFeePaying
|
||||||
satsFeePaid
|
satsFeePaid
|
||||||
status
|
status
|
||||||
|
autoWithdraw
|
||||||
}
|
}
|
||||||
}`
|
}`
|
||||||
|
|
||||||
@ -41,6 +42,7 @@ export const WALLET_HISTORY = gql`
|
|||||||
facts {
|
facts {
|
||||||
id
|
id
|
||||||
bolt11
|
bolt11
|
||||||
|
autoWithdraw
|
||||||
type
|
type
|
||||||
createdAt
|
createdAt
|
||||||
sats
|
sats
|
||||||
|
@ -40,6 +40,13 @@ export const msatsToSats = msats => {
|
|||||||
return Number(BigInt(msats) / 1000n)
|
return Number(BigInt(msats) / 1000n)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const satsToMsats = sats => {
|
||||||
|
if (sats === null || sats === undefined) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return BigInt(sats) * 1000n
|
||||||
|
}
|
||||||
|
|
||||||
export const msatsToSatsDecimal = msats => {
|
export const msatsToSatsDecimal = msats => {
|
||||||
if (msats === null || msats === undefined) {
|
if (msats === null || msats === undefined) {
|
||||||
return null
|
return null
|
||||||
|
@ -28,7 +28,8 @@ export function lnurlPayDescriptionHash (data) {
|
|||||||
export async function lnAddrOptions (addr) {
|
export async function lnAddrOptions (addr) {
|
||||||
await lnAddrSchema().fields.addr.validate(addr)
|
await lnAddrSchema().fields.addr.validate(addr)
|
||||||
const [name, domain] = addr.split('@')
|
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()
|
const res = await req.json()
|
||||||
if (res.status === 'ERROR') {
|
if (res.status === 'ERROR') {
|
||||||
throw new Error(res.reason)
|
throw new Error(res.reason)
|
||||||
|
@ -2,12 +2,12 @@ import { string, ValidationError, number, object, array, addMethod, boolean } fr
|
|||||||
import {
|
import {
|
||||||
BOOST_MIN, MAX_POLL_CHOICE_LENGTH, MAX_TITLE_LENGTH, MAX_POLL_NUM_CHOICES,
|
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,
|
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'
|
} from './constants'
|
||||||
import { URL_REGEXP, WS_REGEXP } from './url'
|
import { URL_REGEXP, WS_REGEXP } from './url'
|
||||||
import { SUPPORTED_CURRENCIES } from './currency'
|
import { SUPPORTED_CURRENCIES } from './currency'
|
||||||
import { NOSTR_MAX_RELAY_NUM, NOSTR_PUBKEY_BECH32, NOSTR_PUBKEY_HEX } from './nostr'
|
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 usersFragments from '../fragments/users'
|
||||||
import * as subsFragments from '../fragments/subs'
|
import * as subsFragments from '../fragments/subs'
|
||||||
const { SUB } = subsFragments
|
const { SUB } = subsFragments
|
||||||
@ -59,6 +59,13 @@ const nameValidator = string()
|
|||||||
.max(32, 'too long')
|
.max(32, 'too long')
|
||||||
|
|
||||||
const intValidator = number().typeError('must be a number').integer('must be whole')
|
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 }) {
|
async function usernameExists (name, { client, models }) {
|
||||||
if (!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) {
|
export function bountySchema (args) {
|
||||||
return object({
|
return object({
|
||||||
title: titleValidator,
|
title: titleValidator,
|
||||||
@ -377,7 +392,7 @@ export const withdrawlSchema = object({
|
|||||||
|
|
||||||
export const lnAddrSchema = ({ payerData, min, max, commentAllowed } = {}) =>
|
export const lnAddrSchema = ({ payerData, min, max, commentAllowed } = {}) =>
|
||||||
object({
|
object({
|
||||||
addr: string().email('address is no good').required('required'),
|
addr: lightningAddressValidator.required('required'),
|
||||||
amount: (() => {
|
amount: (() => {
|
||||||
const schema = intValidator.required('required').positive('must be positive').min(
|
const schema = intValidator.required('required').positive('must be positive').min(
|
||||||
min || 1, `must be at least ${min || 1}`)
|
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 { schnorr } from '@noble/curves/secp256k1'
|
||||||
import { createHash } from 'crypto'
|
import { createHash } from 'crypto'
|
||||||
import { datePivot } from '../../../../lib/time'
|
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 { ssValidate, lud18PayerDataSchema } from '../../../../lib/validate'
|
||||||
import assertGofacYourself from '../../../../api/resolvers/ofac'
|
import assertGofacYourself from '../../../../api/resolvers/ofac'
|
||||||
|
|
||||||
@ -83,7 +83,8 @@ export default async ({ query: { username, amount, nostr, comment, payerdata: pa
|
|||||||
await serialize(models,
|
await serialize(models,
|
||||||
models.$queryRaw`SELECT * FROM create_invoice(${invoice.id}, NULL, ${invoice.request},
|
models.$queryRaw`SELECT * FROM create_invoice(${invoice.id}, NULL, ${invoice.request},
|
||||||
${expiresAt}::timestamp, ${Number(amount)}, ${user.id}::INTEGER, ${noteStr || description},
|
${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({
|
return res.status(200).json({
|
||||||
pr: invoice.request,
|
pr: invoice.request,
|
||||||
|
@ -15,6 +15,7 @@ import { CommentFlat } from '../components/comment'
|
|||||||
import ItemJob from '../components/item-job'
|
import ItemJob from '../components/item-job'
|
||||||
import PageLoading from '../components/page-loading'
|
import PageLoading from '../components/page-loading'
|
||||||
import PayerData from '../components/payer-data'
|
import PayerData from '../components/payer-data'
|
||||||
|
import { Badge } from 'react-bootstrap'
|
||||||
|
|
||||||
export const getServerSideProps = getGetServerSideProps({ query: WALLET_HISTORY, authRequired: true })
|
export const getServerSideProps = getGetServerSideProps({ query: WALLET_HISTORY, authRequired: true })
|
||||||
|
|
||||||
@ -78,7 +79,7 @@ function Satus ({ status }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className='d-block'>
|
<span className='d-inline-block'>
|
||||||
<Icon /><small className={`text-${color} fw-bold ms-2`}>{desc}</small>
|
<Icon /><small className={`text-${color} fw-bold ms-2`}>{desc}</small>
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
@ -132,8 +133,7 @@ function Detail ({ fact }) {
|
|||||||
(fact.description && <span className='d-block'>{fact.description}</span>)}
|
(fact.description && <span className='d-block'>{fact.description}</span>)}
|
||||||
<PayerData data={fact.invoicePayerData} className='text-muted' header />
|
<PayerData data={fact.invoicePayerData} className='text-muted' header />
|
||||||
{fact.invoiceComment && <small className='text-muted'><b>sender says:</b> {fact.invoiceComment}</small>}
|
{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} />{fact.autoWithdraw && <Badge className={styles.badge} bg={null}>autowithdraw</Badge>}
|
||||||
<Satus status={fact.status} />
|
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</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 Alert from 'react-bootstrap/Alert'
|
||||||
import Button from 'react-bootstrap/Button'
|
import Button from 'react-bootstrap/Button'
|
||||||
import InputGroup from 'react-bootstrap/InputGroup'
|
import InputGroup from 'react-bootstrap/InputGroup'
|
||||||
import { CenterLayout } from '../components/layout'
|
import { CenterLayout } from '../../components/layout'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { gql, useMutation, useQuery } from '@apollo/client'
|
import { gql, useMutation, useQuery } from '@apollo/client'
|
||||||
import { getGetServerSideProps } from '../api/ssrApollo'
|
import { getGetServerSideProps } from '../../api/ssrApollo'
|
||||||
import LoginButton from '../components/login-button'
|
import LoginButton from '../../components/login-button'
|
||||||
import { signIn } from 'next-auth/react'
|
import { signIn } from 'next-auth/react'
|
||||||
import { LightningAuth } from '../components/lightning-auth'
|
import { LightningAuth } from '../../components/lightning-auth'
|
||||||
import { SETTINGS, SET_SETTINGS } from '../fragments/users'
|
import { SETTINGS, SET_SETTINGS } from '../../fragments/users'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import Info from '../components/info'
|
import Info from '../../components/info'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import AccordianItem from '../components/accordian-item'
|
import AccordianItem from '../../components/accordian-item'
|
||||||
import { bech32 } from 'bech32'
|
import { bech32 } from 'bech32'
|
||||||
import { NOSTR_MAX_RELAY_NUM, NOSTR_PUBKEY_BECH32, DEFAULT_CROSSPOSTING_RELAYS } from '../lib/nostr'
|
import { NOSTR_MAX_RELAY_NUM, NOSTR_PUBKEY_BECH32, DEFAULT_CROSSPOSTING_RELAYS } from '../../lib/nostr'
|
||||||
import { emailSchema, lastAuthRemovalSchema, settingsSchema } from '../lib/validate'
|
import { emailSchema, lastAuthRemovalSchema, settingsSchema } from '../../lib/validate'
|
||||||
import { SUPPORTED_CURRENCIES } from '../lib/currency'
|
import { SUPPORTED_CURRENCIES } from '../../lib/currency'
|
||||||
import PageLoading from '../components/page-loading'
|
import PageLoading from '../../components/page-loading'
|
||||||
import { useShowModal } from '../components/modal'
|
import { useShowModal } from '../../components/modal'
|
||||||
import { authErrorMessage } from '../components/login'
|
import { authErrorMessage } from '../../components/login'
|
||||||
import { NostrAuth } from '../components/nostr-auth'
|
import { NostrAuth } from '../../components/nostr-auth'
|
||||||
import { useToast } from '../components/toast'
|
import { useToast } from '../../components/toast'
|
||||||
import { useLogger } from '../components/logger'
|
import { useLogger } from '../../components/logger'
|
||||||
import { useMe } from '../components/me'
|
import { useMe } from '../../components/me'
|
||||||
import { INVOICE_RETENTION_DAYS } from '../lib/constants'
|
import { INVOICE_RETENTION_DAYS } from '../../lib/constants'
|
||||||
|
|
||||||
export const getServerSideProps = getGetServerSideProps({ query: SETTINGS, authRequired: true })
|
export const getServerSideProps = getGetServerSideProps({ query: SETTINGS, authRequired: true })
|
||||||
|
|
24
pages/settings/wallets/index.js
Normal file
24
pages/settings/wallets/index.js
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
113
pages/settings/wallets/lightning-address.js
Normal file
113
pages/settings/wallets/lightning-address.js
Normal 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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
@ -28,6 +28,7 @@ import { useShowModal } from '../components/modal'
|
|||||||
import { useField } from 'formik'
|
import { useField } from 'formik'
|
||||||
import { useToast } from '../components/toast'
|
import { useToast } from '../components/toast'
|
||||||
import { WalletLimitBanner } from '../components/banners'
|
import { WalletLimitBanner } from '../components/banners'
|
||||||
|
import Plug from '../svgs/plug.svg'
|
||||||
|
|
||||||
export const getServerSideProps = getGetServerSideProps({ authRequired: true })
|
export const getServerSideProps = getGetServerSideProps({ authRequired: true })
|
||||||
|
|
||||||
@ -84,7 +85,7 @@ function WalletHistory () {
|
|||||||
|
|
||||||
export function WalletForm () {
|
export function WalletForm () {
|
||||||
return (
|
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'>
|
<Link href='/wallet?type=fund'>
|
||||||
<Button variant='success'>fund</Button>
|
<Button variant='success'>fund</Button>
|
||||||
</Link>
|
</Link>
|
||||||
@ -92,6 +93,11 @@ export function WalletForm () {
|
|||||||
<Link href='/wallet?type=withdraw'>
|
<Link href='/wallet?type=withdraw'>
|
||||||
<Button variant='success'>withdraw</Button>
|
<Button variant='success'>withdraw</Button>
|
||||||
</Link>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
70
prisma/migrations/20240109235224_auto_withdraw/migration.sql
Normal file
70
prisma/migrations/20240109235224_auto_withdraw/migration.sql
Normal 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;
|
||||||
|
$$;
|
@ -13,96 +13,99 @@ model Snl {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||||
name String? @unique(map: "users.name_unique") @db.Citext
|
name String? @unique(map: "users.name_unique") @db.Citext
|
||||||
email String? @unique(map: "users.email_unique")
|
email String? @unique(map: "users.email_unique")
|
||||||
emailVerified DateTime? @map("email_verified")
|
emailVerified DateTime? @map("email_verified")
|
||||||
image String?
|
image String?
|
||||||
msats BigInt @default(0)
|
msats BigInt @default(0)
|
||||||
freeComments Int @default(5)
|
freeComments Int @default(5)
|
||||||
freePosts Int @default(2)
|
freePosts Int @default(2)
|
||||||
checkedNotesAt DateTime?
|
checkedNotesAt DateTime?
|
||||||
foundNotesAt DateTime?
|
foundNotesAt DateTime?
|
||||||
pubkey String? @unique(map: "users.pubkey_unique")
|
pubkey String? @unique(map: "users.pubkey_unique")
|
||||||
tipDefault Int @default(100)
|
tipDefault Int @default(100)
|
||||||
bioId Int?
|
bioId Int?
|
||||||
inviteId String?
|
inviteId String?
|
||||||
tipPopover Boolean @default(false)
|
tipPopover Boolean @default(false)
|
||||||
upvotePopover Boolean @default(false)
|
upvotePopover Boolean @default(false)
|
||||||
trust Float @default(0)
|
trust Float @default(0)
|
||||||
lastSeenAt DateTime?
|
lastSeenAt DateTime?
|
||||||
stackedMsats BigInt @default(0)
|
stackedMsats BigInt @default(0)
|
||||||
noteAllDescendants Boolean @default(true)
|
noteAllDescendants Boolean @default(true)
|
||||||
noteTerritoryPosts Boolean @default(true)
|
noteTerritoryPosts Boolean @default(true)
|
||||||
noteDeposits Boolean @default(true)
|
noteDeposits Boolean @default(true)
|
||||||
noteEarning Boolean @default(true)
|
noteEarning Boolean @default(true)
|
||||||
noteInvites Boolean @default(true)
|
noteInvites Boolean @default(true)
|
||||||
noteItemSats Boolean @default(true)
|
noteItemSats Boolean @default(true)
|
||||||
noteMentions Boolean @default(true)
|
noteMentions Boolean @default(true)
|
||||||
noteForwardedSats Boolean @default(true)
|
noteForwardedSats Boolean @default(true)
|
||||||
lastCheckedJobs DateTime?
|
lastCheckedJobs DateTime?
|
||||||
noteJobIndicator Boolean @default(true)
|
noteJobIndicator Boolean @default(true)
|
||||||
photoId Int?
|
photoId Int?
|
||||||
upvoteTrust Float @default(0)
|
upvoteTrust Float @default(0)
|
||||||
hideInvoiceDesc Boolean @default(false)
|
hideInvoiceDesc Boolean @default(false)
|
||||||
wildWestMode Boolean @default(false)
|
wildWestMode Boolean @default(false)
|
||||||
greeterMode Boolean @default(false)
|
greeterMode Boolean @default(false)
|
||||||
fiatCurrency String @default("USD")
|
fiatCurrency String @default("USD")
|
||||||
withdrawMaxFeeDefault Int @default(10)
|
withdrawMaxFeeDefault Int @default(10)
|
||||||
autoDropBolt11s Boolean @default(false)
|
autoDropBolt11s Boolean @default(false)
|
||||||
hideFromTopUsers Boolean @default(false)
|
hideFromTopUsers Boolean @default(false)
|
||||||
turboTipping Boolean @default(false)
|
turboTipping Boolean @default(false)
|
||||||
imgproxyOnly Boolean @default(false)
|
imgproxyOnly Boolean @default(false)
|
||||||
hideWalletBalance Boolean @default(false)
|
hideWalletBalance Boolean @default(false)
|
||||||
referrerId Int?
|
referrerId Int?
|
||||||
nostrPubkey String?
|
nostrPubkey String?
|
||||||
nostrAuthPubkey String? @unique(map: "users.nostrAuthPubkey_unique")
|
nostrAuthPubkey String? @unique(map: "users.nostrAuthPubkey_unique")
|
||||||
nostrCrossposting Boolean @default(false)
|
nostrCrossposting Boolean @default(false)
|
||||||
slashtagId String? @unique(map: "users.slashtagId_unique")
|
slashtagId String? @unique(map: "users.slashtagId_unique")
|
||||||
noteCowboyHat Boolean @default(true)
|
noteCowboyHat Boolean @default(true)
|
||||||
streak Int?
|
streak Int?
|
||||||
subs String[]
|
subs String[]
|
||||||
hideCowboyHat Boolean @default(false)
|
hideCowboyHat Boolean @default(false)
|
||||||
Bookmarks Bookmark[]
|
Bookmarks Bookmark[]
|
||||||
Donation Donation[]
|
Donation Donation[]
|
||||||
Earn Earn[]
|
Earn Earn[]
|
||||||
invites Invite[] @relation("Invites")
|
invites Invite[] @relation("Invites")
|
||||||
invoices Invoice[]
|
invoices Invoice[]
|
||||||
items Item[] @relation("UserItems")
|
items Item[] @relation("UserItems")
|
||||||
actions ItemAct[]
|
actions ItemAct[]
|
||||||
mentions Mention[]
|
mentions Mention[]
|
||||||
messages Message[]
|
messages Message[]
|
||||||
PollVote PollVote[]
|
PollVote PollVote[]
|
||||||
PushSubscriptions PushSubscription[]
|
PushSubscriptions PushSubscription[]
|
||||||
ReferralAct ReferralAct[]
|
ReferralAct ReferralAct[]
|
||||||
Streak Streak[]
|
Streak Streak[]
|
||||||
ThreadSubscriptions ThreadSubscription[]
|
ThreadSubscriptions ThreadSubscription[]
|
||||||
Upload Upload[] @relation("Uploads")
|
Upload Upload[] @relation("Uploads")
|
||||||
nostrRelays UserNostrRelay[]
|
nostrRelays UserNostrRelay[]
|
||||||
withdrawls Withdrawl[]
|
withdrawls Withdrawl[]
|
||||||
bio Item? @relation(fields: [bioId], references: [id])
|
bio Item? @relation(fields: [bioId], references: [id])
|
||||||
invite Invite? @relation(fields: [inviteId], references: [id])
|
invite Invite? @relation(fields: [inviteId], references: [id])
|
||||||
photo Upload? @relation(fields: [photoId], references: [id])
|
photo Upload? @relation(fields: [photoId], references: [id])
|
||||||
referrer User? @relation("referrals", fields: [referrerId], references: [id])
|
referrer User? @relation("referrals", fields: [referrerId], references: [id])
|
||||||
referrees User[] @relation("referrals")
|
referrees User[] @relation("referrals")
|
||||||
Account Account[]
|
Account Account[]
|
||||||
Session Session[]
|
Session Session[]
|
||||||
itemForwards ItemForward[]
|
itemForwards ItemForward[]
|
||||||
hideBookmarks Boolean @default(false)
|
hideBookmarks Boolean @default(false)
|
||||||
followers UserSubscription[] @relation("follower")
|
followers UserSubscription[] @relation("follower")
|
||||||
followees UserSubscription[] @relation("followee")
|
followees UserSubscription[] @relation("followee")
|
||||||
hideWelcomeBanner Boolean @default(false)
|
hideWelcomeBanner Boolean @default(false)
|
||||||
diagnostics Boolean @default(false)
|
diagnostics Boolean @default(false)
|
||||||
hideIsContributor Boolean @default(false)
|
hideIsContributor Boolean @default(false)
|
||||||
muters Mute[] @relation("muter")
|
lnAddr String?
|
||||||
muteds Mute[] @relation("muted")
|
autoWithdrawMaxFeePercent Float?
|
||||||
ArcOut Arc[] @relation("fromUser")
|
autoWithdrawThreshold Int?
|
||||||
ArcIn Arc[] @relation("toUser")
|
muters Mute[] @relation("muter")
|
||||||
Sub Sub[]
|
muteds Mute[] @relation("muted")
|
||||||
SubAct SubAct[]
|
ArcOut Arc[] @relation("fromUser")
|
||||||
MuteSub MuteSub[]
|
ArcIn Arc[] @relation("toUser")
|
||||||
|
Sub Sub[]
|
||||||
|
SubAct SubAct[]
|
||||||
|
MuteSub MuteSub[]
|
||||||
|
|
||||||
@@index([photoId])
|
@@index([photoId])
|
||||||
@@index([createdAt], map: "users.created_at_index")
|
@@index([createdAt], map: "users.created_at_index")
|
||||||
@ -567,6 +570,7 @@ model Withdrawl {
|
|||||||
msatsFeePaying BigInt
|
msatsFeePaying BigInt
|
||||||
msatsFeePaid BigInt?
|
msatsFeePaid BigInt?
|
||||||
status WithdrawlStatus?
|
status WithdrawlStatus?
|
||||||
|
autoWithdraw Boolean @default(false)
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@index([createdAt], map: "Withdrawl.created_at_index")
|
@@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-brandColor: rgba(0, 0, 0, 0.9);
|
||||||
--theme-grey: #707070;
|
--theme-grey: #707070;
|
||||||
--theme-link: #007cbe;
|
--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-quoteBar: rgb(206, 208, 212);
|
||||||
--theme-linkHover: #004a72;
|
--theme-linkHover: #004a72;
|
||||||
--theme-linkVisited: #53758;
|
--theme-linkVisited: #53758;
|
||||||
@ -753,7 +755,7 @@ div[contenteditable]:focus,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.fill-danger {
|
.fill-danger {
|
||||||
fill: var(--bs-danger);
|
fill: var(--bs-danger) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fill-theme-color {
|
.fill-theme-color {
|
||||||
|
@ -21,6 +21,13 @@
|
|||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
color: var(--theme-grey) !important;
|
||||||
|
background: var(--theme-clickToContextColor) !important;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.detail {
|
.detail {
|
||||||
border-bottom: 2px solid var(--theme-clickToContextColor);
|
border-bottom: 2px solid var(--theme-clickToContextColor);
|
||||||
padding: .5rem;
|
padding: .5rem;
|
||||||
|
57
styles/wallet.module.css
Normal file
57
styles/wallet.module.css
Normal 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
1
svgs/plug.svg
Normal 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 |
@ -1,7 +1,10 @@
|
|||||||
import PgBoss from 'pg-boss'
|
import PgBoss from 'pg-boss'
|
||||||
import nextEnv from '@next/env'
|
import nextEnv from '@next/env'
|
||||||
import { PrismaClient } from '@prisma/client'
|
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 { repin } from './repin.js'
|
||||||
import { trust } from './trust.js'
|
import { trust } from './trust.js'
|
||||||
import { auction } from './auction.js'
|
import { auction } from './auction.js'
|
||||||
@ -77,6 +80,7 @@ async function work () {
|
|||||||
await boss.work('checkPendingDeposits', jobWrapper(checkPendingDeposits))
|
await boss.work('checkPendingDeposits', jobWrapper(checkPendingDeposits))
|
||||||
await boss.work('checkPendingWithdrawals', jobWrapper(checkPendingWithdrawals))
|
await boss.work('checkPendingWithdrawals', jobWrapper(checkPendingWithdrawals))
|
||||||
await boss.work('autoDropBolt11s', jobWrapper(autoDropBolt11s))
|
await boss.work('autoDropBolt11s', jobWrapper(autoDropBolt11s))
|
||||||
|
await boss.work('autoWithdraw', jobWrapper(autoWithdraw))
|
||||||
await boss.work('repin-*', jobWrapper(repin))
|
await boss.work('repin-*', jobWrapper(repin))
|
||||||
await boss.work('trust', jobWrapper(trust))
|
await boss.work('trust', jobWrapper(trust))
|
||||||
await boss.work('timestampItem', jobWrapper(timestampItem))
|
await boss.work('timestampItem', jobWrapper(timestampItem))
|
||||||
|
@ -4,9 +4,10 @@ import {
|
|||||||
subscribeToInvoices, subscribeToPayments, subscribeToInvoice
|
subscribeToInvoices, subscribeToPayments, subscribeToInvoice
|
||||||
} from 'ln-service'
|
} from 'ln-service'
|
||||||
import { sendUserNotification } from '../api/webPush/index.js'
|
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 { INVOICE_RETENTION_DAYS } from '../lib/constants'
|
||||||
import { sleep } from '../lib/time.js'
|
import { sleep } from '../lib/time.js'
|
||||||
|
import { sendToLnAddr } from '../api/resolvers/wallet.js'
|
||||||
import retry from 'async-retry'
|
import retry from 'async-retry'
|
||||||
|
|
||||||
export async function subscribeToWallet (args) {
|
export async function subscribeToWallet (args) {
|
||||||
@ -27,6 +28,7 @@ function subscribeForever (subscribe) {
|
|||||||
sub.on('error', reject)
|
sub.on('error', reject)
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
throw new Error('error subscribing - trying again')
|
throw new Error('error subscribing - trying again')
|
||||||
} finally {
|
} finally {
|
||||||
sub?.removeAllListeners()
|
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…
x
Reference in New Issue
Block a user