Compare commits

...

4 Commits

Author SHA1 Message Date
SatsAllDay dc0370ba17
random zap amounts (#1263)
* add random zapping support

adds an option to enable random zap amounts per stacker

configurable in settings, you can enable this feature and provide
an upper and lower range of your random zap amount

* rename github eslint check to lint

this has been bothering me since we aren't using eslint for linting

* fixup! add random zapping support

* fixup! rename github eslint check to lint

* fixup! fixup! add random zapping support

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2024-07-26 22:37:03 -05:00
keyan 4964e2c7d1 order all zap recipients 2024-07-26 12:25:48 -05:00
keyan 3d1f7834ca fix potential zap deadlock 2024-07-25 20:46:17 -05:00
ekzyis 628a0466fd
Use validation for WebLN wallet (#1277)
* remove available prop
* 'enabled' checkbox is now always enabled but uses validation
* CheckboxGroup was missing to show error message
2024-07-24 10:08:09 -05:00
16 changed files with 245 additions and 87 deletions

View File

@ -1,8 +1,8 @@
name: Eslint Check
name: Lint Check
on: [pull_request]
jobs:
eslint-run:
lint-run:
runs-on: ubuntu-latest
steps:
- name: Checkout

View File

@ -201,4 +201,69 @@ From the [postgres docs](https://www.postgresql.org/docs/current/transaction-iso
> UPDATE, DELETE, SELECT FOR UPDATE, and SELECT FOR SHARE commands behave the same as SELECT in terms of searching for target rows: they will only find target rows that were committed as of the command start time. However, such a target row might have already been updated (or deleted or locked) by another concurrent transaction by the time it is found. In this case, the would-be updater will wait for the first updating transaction to commit or roll back (if it is still in progress). If the first updater rolls back, then its effects are negated and the second updater can proceed with updating the originally found row. If the first updater commits, the second updater will ignore the row if the first updater deleted it, otherwise it will attempt to apply its operation to the updated version of the row. The search condition of the command (the WHERE clause) is re-evaluated to see if the updated version of the row still matches the search condition. If so, the second updater proceeds with its operation using the updated version of the row. In the case of SELECT FOR UPDATE and SELECT FOR SHARE, this means it is the updated version of the row that is locked and returned to the client.
From the [postgres source docs](https://git.postgresql.org/gitweb/?p=postgresql.git;a=blob;f=src/backend/executor/README#l350):
> It is also possible that there are relations in the query that are not to be locked (they are neither the UPDATE/DELETE/MERGE target nor specified to be locked in SELECT FOR UPDATE/SHARE). When re-running the test query ***we want to use the same rows*** from these relations that were joined to the locked rows.
> It is also possible that there are relations in the query that are not to be locked (they are neither the UPDATE/DELETE/MERGE target nor specified to be locked in SELECT FOR UPDATE/SHARE). When re-running the test query ***we want to use the same rows*** from these relations that were joined to the locked rows.
## `IMPORTANT: deadlocks`
Deadlocks can occur when two transactions are waiting for each other to release locks. This can happen when two transactions lock rows in different orders whether explicit or implicit.
### Incorrect
```sql
-- transaction 1
BEGIN;
UPDATE users set msats = msats + 1 WHERE id = 1;
-- transaction 2
BEGIN;
UPDATE users set msats = msats + 1 WHERE id = 2;
-- transaction 1 (blocks here until transaction 2 commits)
UPDATE users set msats = msats + 1 WHERE id = 2;
-- transaction 2 (blocks here until transaction 1 commits)
UPDATE users set msats = msats + 1 WHERE id = 1;
-- deadlock occurs because neither transaction can proceed to here
```
If both transactions lock the rows in the same order, the deadlock is avoided.
Most often this occurs when selecting multiple rows for update in different orders. Recently, we had a deadlock when spliting zaps to multiple users. The solution was to select the rows for update in the same order.
### Incorrect
```sql
WITH forwardees AS (
SELECT "userId", (($1::BIGINT * pct) / 100)::BIGINT AS msats
FROM "ItemForward"
WHERE "itemId" = $2::INTEGER
),
UPDATE users
SET
msats = users.msats + forwardees.msats,
"stackedMsats" = users."stackedMsats" + forwardees.msats
FROM forwardees
WHERE users.id = forwardees."userId";
```
If forwardees are selected in a different order in two concurrent transactions, e.g. (1,2) in tx 1 and (2,1) in tx 2, a deadlock can occur. To avoid this, always select rows for update in the same order.
### Correct
We fixed the deadlock by selecting the forwardees in the same order in these transactions.
```sql
WITH forwardees AS (
SELECT "userId", (($1::BIGINT * pct) / 100)::BIGINT AS msats
FROM "ItemForward"
WHERE "itemId" = $2::INTEGER
ORDER BY "userId" ASC
),
UPDATE users
SET
msats = users.msats + forwardees.msats,
"stackedMsats" = users."stackedMsats" + forwardees.msats
FROM forwardees
WHERE users.id = forwardees."userId";
```
### More resources
- https://www.postgresql.org/docs/current/explicit-locking.html#LOCKING-DEADLOCKS

View File

@ -77,19 +77,19 @@ export async function onPaid ({ invoice, actIds }, { models, tx }) {
), total_forwarded AS (
SELECT COALESCE(SUM(msats), 0) as msats
FROM forwardees
), forward AS (
UPDATE users
SET
msats = users.msats + forwardees.msats,
"stackedMsats" = users."stackedMsats" + forwardees.msats
FROM forwardees
WHERE users.id = forwardees."userId"
), recipients AS (
SELECT "userId", msats FROM forwardees
UNION
SELECT ${itemAct.item.userId}::INTEGER as "userId",
${itemAct.msats}::BIGINT - (SELECT msats FROM total_forwarded)::BIGINT as msats
ORDER BY "userId" ASC -- order to prevent deadlocks
)
UPDATE users
SET
msats = msats + ${itemAct.msats}::BIGINT - (SELECT msats FROM total_forwarded)::BIGINT,
"stackedMsats" = "stackedMsats" + ${itemAct.msats}::BIGINT - (SELECT msats FROM total_forwarded)::BIGINT
WHERE id = ${itemAct.item.userId}::INTEGER`
msats = users.msats + recipients.msats,
"stackedMsats" = users."stackedMsats" + recipients.msats
FROM recipients
WHERE users.id = recipients."userId"`
// perform denomormalized aggregates: weighted votes, upvotes, msats, lastZapAt
// NOTE: for the rows that might be updated by a concurrent zap, we use UPDATE for implicit locking

View File

@ -1004,6 +1004,12 @@ export default {
})
return relays?.map(r => r.nostrRelayAddr)
},
tipRandom: async (user, args, { me }) => {
if (!me || me.id !== user.id) {
return false
}
return !!user.tipRandomMin && !!user.tipRandomMax
}
},

View File

@ -98,6 +98,8 @@ export default gql`
noteItemMentions: Boolean!
nsfwMode: Boolean!
tipDefault: Int!
tipRandomMin: Int
tipRandomMax: Int
turboTipping: Boolean!
zapUndos: Int
wildWestMode: Boolean!
@ -165,6 +167,9 @@ export default gql`
noteItemMentions: Boolean!
nsfwMode: Boolean!
tipDefault: Int!
tipRandom: Boolean!
tipRandomMin: Int
tipRandomMax: Int
turboTipping: Boolean!
zapUndos: Int
wildWestMode: Boolean!

View File

@ -7,7 +7,7 @@ import UpBolt from '@/svgs/bolt.svg'
import { amountSchema } from '@/lib/validate'
import { useToast } from './toast'
import { useLightning } from './lightning'
import { nextTip } from './upvote'
import { nextTip, defaultTipIncludingRandom } from './upvote'
import { ZAP_UNDO_DELAY_MS } from '@/lib/constants'
import { usePaidMutation } from './use-paid-mutation'
import { ACT_MUTATION } from '@/fragments/paidAction'
@ -101,7 +101,7 @@ export default function ItemAct ({ onClose, item, down, children, abortSignal })
return (
<Form
initial={{
amount: me?.privates?.tipDefault || defaultTips[0],
amount: defaultTipIncludingRandom(me?.privates) || defaultTips[0],
default: false
}}
schema={amountSchema}

View File

@ -26,7 +26,7 @@ const UpvotePopover = ({ target, show, handleClose }) => {
<button type='button' className='btn-close' onClick={handleClose}><span className='visually-hidden-focusable'>Close alert</span></button>
</Popover.Header>
<Popover.Body>
<div className='mb-2'>Press the bolt again to zap {me?.privates?.tipDefault || 1} more sat{me?.privates?.tipDefault > 1 ? 's' : ''}.</div>
<div className='mb-2'>Press the bolt again to zap {me?.privates?.tipRandom ? 'a random amount of' : `${me?.privates?.tipDefault || 1} more`} sat{me?.privates?.tipDefault > 1 ? 's' : ''}.</div>
<div>Repeatedly press the bolt to zap more sats.</div>
</Popover.Body>
</Popover>
@ -67,11 +67,20 @@ export function DropdownItemUpVote ({ item }) {
)
}
export const nextTip = (meSats, { tipDefault, turboTipping }) => {
// what should our next tip be?
if (!turboTipping) return (tipDefault || 1)
export const defaultTipIncludingRandom = ({ tipDefault, tipRandom, tipRandomMin, tipRandomMax }) =>
tipRandom
? Math.floor((Math.random() * (tipRandomMax - tipRandomMin)) + tipRandomMin)
: (tipDefault || 1)
let sats = tipDefault || 1
export const nextTip = (meSats, { tipDefault, turboTipping, tipRandom, tipRandomMin, tipRandomMax }) => {
// what should our next tip be?
const calculatedDefault = defaultTipIncludingRandom({ tipDefault, tipRandom, tipRandomMin, tipRandomMax })
if (!turboTipping) {
return calculatedDefault
}
let sats = calculatedDefault
if (turboTipping) {
while (meSats >= sats) {
sats *= 10
@ -138,11 +147,17 @@ export default function UpVote ({ item, className }) {
// what should our next tip be?
const sats = nextTip(meSats, { ...me?.privates })
let overlayTextContent
if (me) {
overlayTextContent = me.privates?.tipRandom ? 'random' : numWithUnits(sats, { abbreviate: false })
} else {
overlayTextContent = 'zap it'
}
return [
meSats, me ? numWithUnits(sats, { abbreviate: false }) : 'zap it',
meSats, overlayTextContent,
getColor(meSats), getColor(meSats + sats)]
}, [item?.meSats, item?.meAnonSats, me?.privates?.tipDefault, me?.privates?.turboDefault])
}, [item?.meSats, item?.meAnonSats, me?.privates?.tipDefault, me?.privates?.turboDefault, me?.privates?.tipRandom, me?.privates?.tipRandomMin, me?.privates?.tipRandomMax])
const handleModalClosed = () => {
setHover(false)

View File

@ -41,6 +41,9 @@ export const ME = gql`
noteItemMentions
sats
tipDefault
tipRandom
tipRandomMin
tipRandomMax
tipPopover
turboTipping
zapUndos
@ -66,6 +69,9 @@ export const SETTINGS_FIELDS = gql`
fragment SettingsFields on User {
privates {
tipDefault
tipRandom
tipRandomMin
tipRandomMax
turboTipping
zapUndos
fiatCurrency

View File

@ -540,8 +540,24 @@ export const actSchema = object({
act: string().required('required').oneOf(['TIP', 'DONT_LIKE_THIS'])
})
export const settingsSchema = object({
export const settingsSchema = object().shape({
tipDefault: intValidator.required('required').positive('must be positive'),
tipRandomMin: intValidator.nullable().positive('must be positive')
.when(['tipRandomMax'], ([max], schema) => {
let res = schema
if (max) {
res = schema.required('minimum and maximum must either both be omitted or specified').nonNullable()
}
return res.lessThan(max, 'must be less than maximum')
}),
tipRandomMax: intValidator.nullable().positive('must be positive')
.when(['tipRandomMin'], ([min], schema) => {
let res = schema
if (min) {
res = schema.required('minimum and maximum must either both be omitted or specified').nonNullable()
}
return res.moreThan(min, 'must be more than minimum')
}),
fiatCurrency: string().required('required').oneOf(SUPPORTED_CURRENCIES),
withdrawMaxFeeDefault: intValidator.required('required').positive('must be positive'),
nostrPubkey: string().nullable()
@ -561,7 +577,8 @@ export const settingsSchema = object({
noReferralLinks: boolean(),
hideIsContributor: boolean(),
zapUndos: intValidator.nullable().min(0, 'must be greater or equal to 0')
})
// exclude from cyclic analysis. see https://github.com/jquense/yup/issues/720
}, [['tipRandomMax', 'tipRandomMin']])
const warningMessage = 'If I logout, even accidentally, I will never be able to access my account again'
export const lastAuthRemovalSchema = object({

View File

@ -112,6 +112,9 @@ export default function Settings ({ ssrData }) {
<Form
initial={{
tipDefault: settings?.tipDefault || 21,
tipRandom: settings?.tipRandom,
tipRandomMin: settings?.tipRandomMin || 1,
tipRandomMax: settings?.tipRandomMax || settings?.tipDefault || 21,
turboTipping: settings?.turboTipping,
zapUndos: settings?.zapUndos || (settings?.tipDefault ? 100 * settings.tipDefault : 2100),
zapUndosEnabled: settings?.zapUndos !== null,
@ -149,7 +152,7 @@ export default function Settings ({ ssrData }) {
noReferralLinks: settings?.noReferralLinks
}}
schema={settingsSchema}
onSubmit={async ({ tipDefault, withdrawMaxFeeDefault, zapUndos, zapUndosEnabled, nostrPubkey, nostrRelays, ...values }) => {
onSubmit={async ({ tipDefault, tipRandom, tipRandomMin, tipRandomMax, withdrawMaxFeeDefault, zapUndos, zapUndosEnabled, nostrPubkey, nostrRelays, ...values }) => {
if (nostrPubkey.length === 0) {
nostrPubkey = null
} else {
@ -166,6 +169,8 @@ export default function Settings ({ ssrData }) {
variables: {
settings: {
tipDefault: Number(tipDefault),
tipRandomMin: tipRandom ? Number(tipRandomMin) : null,
tipRandomMax: tipRandom ? Number(tipRandomMax) : null,
withdrawMaxFeeDefault: Number(withdrawMaxFeeDefault),
zapUndos: zapUndosEnabled ? Number(zapUndos) : null,
nostrPubkey,
@ -190,7 +195,7 @@ export default function Settings ({ ssrData }) {
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
hint={<small className='text-muted'>note: you can also press and hold the lightning bolt to zap custom amounts</small>}
/>
<div className='mb-2'>
<div className='pb-4'>
<AccordianItem
show={settings?.turboTipping}
header={<div style={{ fontWeight: 'bold', fontSize: '92%' }}>advanced</div>}
@ -224,6 +229,7 @@ export default function Settings ({ ssrData }) {
groupClassName='mb-0'
/>
<ZapUndosField />
<TipRandomField />
</>
}
/>
@ -995,31 +1001,79 @@ function ApiKeyDeleteObstacle ({ onClose }) {
const ZapUndosField = () => {
const [checkboxField] = useField({ name: 'zapUndosEnabled' })
return (
<div className='d-flex flex-row align-items-center'>
<Input
name='zapUndos'
disabled={!checkboxField.value}
<>
<Checkbox
name='zapUndosEnabled'
groupClassName='mb-0'
label={
<Checkbox
name='zapUndosEnabled'
groupClassName='mb-0'
label={
<div className='d-flex align-items-center'>
zap undos
<Info>
<ul className='fw-bold'>
<li>After every zap that exceeds or is equal to the threshold, the bolt will pulse</li>
<li>You can undo the zap if you click the bolt while it's pulsing</li>
<li>The bolt will pulse for {ZAP_UNDO_DELAY_MS / 1000} seconds</li>
</ul>
</Info>
</div>
}
/>
}
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
hint={<small className='text-muted'>threshold at which undo button is shown</small>}
<div className='d-flex align-items-center'>
zap undos
<Info>
<ul className='fw-bold'>
<li>After every zap that exceeds or is equal to the threshold, the bolt will pulse</li>
<li>You can undo the zap if you click the bolt while it's pulsing</li>
<li>The bolt will pulse for {ZAP_UNDO_DELAY_MS / 1000} seconds</li>
</ul>
</Info>
</div>
}
/>
</div>
{checkboxField.value &&
<Input
name='zapUndos'
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
hint={<small className='text-muted'>threshold at which undo button is shown</small>}
/>}
</>
)
}
const TipRandomField = () => {
const [tipRandomField] = useField({ name: 'tipRandom' })
const [tipRandomMinField] = useField({ name: 'tipRandomMin' })
const [tipRandomMaxField] = useField({ name: 'tipRandomMax' })
return (
<>
<Checkbox
name='tipRandom'
groupClassName='mb-0'
label={
<div className='d-flex align-items-center'>
Enable random zap values
<Info>
<ul className='fw-bold'>
<li>Set a minimum and maximum zap amount</li>
<li>Each time you zap something, a random amount of sats between your minimum and maximum will be zapped</li>
<li>If this setting is enabled, it will ignore your default zap amount</li>
</ul>
</Info>
</div>
}
/>
{tipRandomField.value &&
<>
<Input
type='number'
label='minimum random zap'
name='tipRandomMin'
disabled={!tipRandomField.value}
groupClassName='mb-1'
required
autoFocus
max={tipRandomMaxField.value ? tipRandomMaxField.value - 1 : undefined}
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
/>
<Input
type='number'
label='maximum random zap'
name='tipRandomMax'
disabled={!tipRandomField.value}
required
autoFocus
min={tipRandomMinField.value ? tipRandomMinField.value + 1 : undefined}
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
/>
</>}
</>
)
}

View File

@ -1,5 +1,5 @@
import { getGetServerSideProps } from '@/api/ssrApollo'
import { Form, ClientInput, ClientCheckbox, PasswordInput } from '@/components/form'
import { Form, ClientInput, ClientCheckbox, PasswordInput, CheckboxGroup } from '@/components/form'
import { CenterLayout } from '@/components/layout'
import { WalletSecurityBanner } from '@/components/banners'
import { WalletLogs } from '@/components/wallet-logger'
@ -10,7 +10,6 @@ import Info from '@/components/info'
import Text from '@/components/text'
import { AutowithdrawSettings } from '@/components/autowithdraw-shared'
import dynamic from 'next/dynamic'
import { useEffect, useState } from 'react'
const WalletButtonBar = dynamic(() => import('@/components/wallet-buttonbar.js'), { ssr: false })
@ -22,14 +21,6 @@ export default function WalletSettings () {
const { wallet: name } = router.query
const wallet = useWallet(name)
const [mounted, setMounted] = useState(false)
useEffect(() => {
// mounted is required since available might depend
// on values that are only available on the client (and not during SSR)
// and thus we need to render the component again on the client
setMounted(true)
}, [])
const initial = wallet.fields.reduce((acc, field) => {
// We still need to run over all wallet fields via reduce
// even though we use wallet.config as the initial value
@ -47,8 +38,6 @@ export default function WalletSettings () {
? { validate: wallet.fieldValidation }
: { schema: wallet.fieldValidation }
const available = mounted && wallet.available !== undefined ? wallet.available : wallet.isConfigured
return (
<CenterLayout>
<h2 className='pb-2'>{wallet.card.title}</h2>
@ -84,12 +73,15 @@ export default function WalletSettings () {
{wallet.walletType
? <AutowithdrawSettings wallet={wallet} />
: (
<ClientCheckbox
disabled={!available}
initialValue={wallet.status === Status.Enabled}
label='enabled'
name='enabled'
/>
<CheckboxGroup name='enabled'>
<ClientCheckbox
disabled={!wallet.isConfigured}
initialValue={wallet.status === Status.Enabled}
label='enabled'
name='enabled'
groupClassName='mb-0'
/>
</CheckboxGroup>
)}
<WalletButtonBar
wallet={wallet} onDelete={async () => {

View File

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "tipRandomMax" INTEGER,
ADD COLUMN "tipRandomMin" INTEGER;

View File

@ -30,6 +30,8 @@ model User {
apiKeyHash String? @unique(map: "users.apikeyhash_unique") @db.Char(64)
apiKeyEnabled Boolean @default(false)
tipDefault Int @default(100)
tipRandomMin Int?
tipRandomMax Int?
bioId Int?
inviteId String?
tipPopover Boolean @default(false)

View File

@ -64,10 +64,6 @@ Since `name` will also be used in [wallet logs](https://stacker.news/wallet/logs
Wallet fields define what this wallet requires for configuration and thus are used to construct the forms like the one you can see at [/settings/wallets/lnbits](https://stacker.news/settings/walletslnbits).
- `available?: boolean`
This property can be used to override the default behavior of the `enabled` checkbox in the wallet configuration form. By default, it will be clickable when a wallet is configured. However, if a wallet does not have any configuration, this checkbox will always be disabled. You can set `available` to an expression that will determine when a wallet can be enabled.
- `card: WalletCard`
Wallet cards are the components you can see at [/settings/wallets](https://stacker.news/settings/wallets). This property customizes this card for this wallet.

View File

@ -137,11 +137,6 @@ function useConfig (wallet) {
...(hasServerConfig ? serverConfig : {})
}
if (wallet?.available !== undefined && config.enabled !== undefined) {
// wallet must be available to be enabled
config.enabled &&= wallet.available
}
const saveConfig = useCallback(async (config) => {
if (hasLocalConfig) setLocalConfig(config)
if (hasServerConfig) await setServerConfig(config)
@ -265,13 +260,7 @@ export function getEnabledWallet (me) {
const priority = config?.priority
return { ...def, config, priority }
})
.filter(({ available, config }) => {
if (available !== undefined && config?.enabled !== undefined) {
// wallet must be available to be enabled
config.enabled &&= available
}
return config?.enabled
})
.filter(({ config }) => config?.enabled)
.sort(walletPrioritySort)[0]
}

View File

@ -1,10 +1,18 @@
import { SSR } from '@/lib/constants'
export const name = 'webln'
export const fields = []
export const available = SSR ? false : typeof window.webln !== 'undefined'
export const fieldValidation = ({ enabled }) => {
if (typeof window.webln === 'undefined') {
// don't prevent disabling WebLN if no WebLN provider found
if (enabled) {
return {
enabled: 'no WebLN provider found'
}
}
}
return {}
}
export const card = {
title: 'WebLN',