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/banners.js b/components/banners.js
index 79f0f039..50dee849 100644
--- a/components/banners.js
+++ b/components/banners.js
@@ -80,7 +80,7 @@ export function WalletLimitBanner () {
Your wallet is over the current limit ({numWithUnits(msatsToSats(BALANCE_LIMIT_MSATS))})
- You will not be able to deposit any more funds from outside of SN.
+ Deposits to your wallet from outside of SN are blocked.
Please spend or withdraw sats to restore full wallet functionality.
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
+
+
+ )
+}
+
+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/scripts/newsletter.js b/scripts/newsletter.js
index 4a73e607..d85d9256 100644
--- a/scripts/newsletter.js
+++ b/scripts/newsletter.js
@@ -66,15 +66,18 @@ async function bountyWinner (q) {
variables: { q: `${q} nym:sn`, sort: 'recent', what: 'posts', when: 'week' }
})
+ const items = bounty.data.search.items.filter(i => i.bountyPaidTo?.length > 0)
+ if (items.length === 0) return
+
try {
const item = await client.query({
query: WINNER,
- variables: { id: bounty.data.search.items[0].bountyPaidTo[0] }
+ variables: { id: items[0].bountyPaidTo[0] }
})
const winner = { ...item.data.item, image: Object.values(item.data.item.imgproxyUrls)[0]?.['640w'] }
- return { bounty: bounty.data.search.items[0].id, winner }
+ return { bounty: items[0].id, winner }
} catch (e) {
}
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 })
+}