diff --git a/api/resolvers/user.js b/api/resolvers/user.js index f51d3abc..3a42f313 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -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' } }) diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index 05de8f8a..32d6e981 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -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 }) +} diff --git a/api/typeDefs/user.js b/api/typeDefs/user.js index 46006561..b573a1c3 100644 --- a/api/typeDefs/user.js +++ b/api/typeDefs/user.js @@ -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 { diff --git a/api/typeDefs/wallet.js b/api/typeDefs/wallet.js index 6ec118ad..d170060d 100644 --- a/api/typeDefs/wallet.js +++ b/api/typeDefs/wallet.js @@ -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 diff --git a/components/bolt11-info.js b/components/bolt11-info.js index bdfa7d5f..f7bf0d8f 100644 --- a/components/bolt11-info.js +++ b/components/bolt11-info.js @@ -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 ( -
+
) diff --git a/components/wallet-card.js b/components/wallet-card.js new file mode 100644 index 00000000..50ce06df --- /dev/null +++ b/components/wallet-card.js @@ -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 ( + +
+ + {title} + + {badges?.map( + badge => + + {badge} + )} + + + {provider && + + + {enabled + ? <>configure + : <>attach} + + } + + ) +} + +export function WalletButtonBar ({ + enabled, disable, + className, children, onDelete, onCancel, hasCancel = true, + createText = 'attach', deleteText = 'unattach', editText = 'save' +}) { + return ( +
+
+ {enabled && + } + {children} +
+ {hasCancel && } + {enabled ? editText : createText} +
+
+
+ ) +} diff --git a/fragments/users.js b/fragments/users.js index 3fc4f433..13030e5e 100644 --- a/fragments/users.js +++ b/fragments/users.js @@ -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!) { diff --git a/fragments/wallet.js b/fragments/wallet.js index 629bebda..d530cc9c 100644 --- a/fragments/wallet.js +++ b/fragments/wallet.js @@ -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 diff --git a/lib/format.js b/lib/format.js index ebcc0519..b165758b 100644 --- a/lib/format.js +++ b/lib/format.js @@ -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 diff --git a/lib/lnurl.js b/lib/lnurl.js index f351a13e..e1584008 100644 --- a/lib/lnurl.js +++ b/lib/lnurl.js @@ -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) diff --git a/lib/validate.js b/lib/validate.js index 8363a6aa..490c32b5 100644 --- a/lib/validate.js +++ b/lib/validate.js @@ -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}`) diff --git a/pages/api/lnurlp/[username]/pay.js b/pages/api/lnurlp/[username]/pay.js index eaa30042..b293f963 100644 --- a/pages/api/lnurlp/[username]/pay.js +++ b/pages/api/lnurlp/[username]/pay.js @@ -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, diff --git a/pages/satistics.js b/pages/satistics.js index 2798977e..d6e60f77 100644 --- a/pages/satistics.js +++ b/pages/satistics.js @@ -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 ( - + {desc} ) @@ -132,8 +133,7 @@ function Detail ({ fact }) { (fact.description && {fact.description})} {fact.invoiceComment && sender says: {fact.invoiceComment}} - {!fact.invoiceComment && !fact.description && fact.bolt11 && no description} - + {fact.autoWithdraw && autowithdraw}
) diff --git a/pages/settings.js b/pages/settings/index.js similarity index 95% rename from pages/settings.js rename to pages/settings/index.js index b731b461..935dbb48 100644 --- a/pages/settings.js +++ b/pages/settings/index.js @@ -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 }) diff --git a/pages/settings/wallets/index.js b/pages/settings/wallets/index.js new file mode 100644 index 00000000..bcb34d84 --- /dev/null +++ b/pages/settings/wallets/index.js @@ -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 ( + +
+

attach wallets

+
attach wallets to supplement your SN wallet
+
+ + + + +
+
+
+ ) +} diff --git a/pages/settings/wallets/lightning-address.js b/pages/settings/wallets/lightning-address.js new file mode 100644 index 00000000..7cc2b38f --- /dev/null +++ b/pages/settings/wallets/lightning-address.js @@ -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 ( + +

lightning address

+
autowithdraw to a lightning address when threshold is breached
+
{ + 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?.()) + } + }} + > + + { + 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={sats} + /> + %} + /> + { + 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?.()) + } + }} + /> + +
+ ) +} + +export function LightningAddressWalletCard () { + const enabled = useAutoWithdrawEnabled() + + return ( + + ) +} diff --git a/pages/wallet.js b/pages/wallet.js index 52bec1bc..6f9845fb 100644 --- a/pages/wallet.js +++ b/pages/wallet.js @@ -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 ( -
+
@@ -92,6 +93,11 @@ export function WalletForm () { +
+ + + +
) } diff --git a/prisma/migrations/20240109235224_auto_withdraw/migration.sql b/prisma/migrations/20240109235224_auto_withdraw/migration.sql new file mode 100644 index 00000000..68e62e3c --- /dev/null +++ b/prisma/migrations/20240109235224_auto_withdraw/migration.sql @@ -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; +$$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5368f8c8..248aca0e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") diff --git a/styles/globals.scss b/styles/globals.scss index d8071cf7..d0881a0f 100644 --- a/styles/globals.scss +++ b/styles/globals.scss @@ -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 { diff --git a/styles/satistics.module.css b/styles/satistics.module.css index e37cce38..85a8df2b 100644 --- a/styles/satistics.module.css +++ b/styles/satistics.module.css @@ -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; diff --git a/styles/wallet.module.css b/styles/wallet.module.css new file mode 100644 index 00000000..4cdd3ef1 --- /dev/null +++ b/styles/wallet.module.css @@ -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); +} \ No newline at end of file diff --git a/svgs/plug.svg b/svgs/plug.svg new file mode 100644 index 00000000..ff22b820 --- /dev/null +++ b/svgs/plug.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/worker/index.js b/worker/index.js index 4036b2d9..bc42f4b3 100644 --- a/worker/index.js +++ b/worker/index.js @@ -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)) diff --git a/worker/wallet.js b/worker/wallet.js index b4128a5e..5e0db637 100644 --- a/worker/wallet.js +++ b/worker/wallet.js @@ -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 }) +}