feat: add advanced address blacklisting

This commit is contained in:
Sérgio Salgado 2022-10-06 17:18:12 +01:00 committed by Rafael
parent 473bb15c24
commit 8af7c97c16
7 changed files with 367 additions and 66 deletions

View file

@ -1,16 +1,16 @@
const _ = require('lodash/fp')
const db = require('./db') const db = require('./db')
const notifierQueries = require('./notifier/queries') const notifierQueries = require('./notifier/queries')
// Get all blacklist rows from the DB "blacklist" table that were manually inserted by the operator
const getBlacklist = () => { const getBlacklist = () => {
return db.any(`SELECT * FROM blacklist`).then(res => const blacklistSql = `SELECT * FROM blacklist`
res.map(item => ({ const messagesSql = `SELECT * FROM blacklist_messages`
address: item.address return Promise.all([db.any(blacklistSql), db.any(messagesSql)])
})) .then(([blacklist, messages]) => Promise.all([_.map(_.mapKeys(_.camelCase), blacklist), _.map(_.mapKeys(_.camelCase), messages)]))
) .then(([blacklist, messages]) => _.map(it => ({ ...it, blacklistMessage: _.find(ite => it.blacklistMessageId === ite.id, messages) }), blacklist))
} }
// Delete row from blacklist table by crypto code and address
const deleteFromBlacklist = address => { const deleteFromBlacklist = address => {
const sql = `DELETE FROM blacklist WHERE address = $1` const sql = `DELETE FROM blacklist WHERE address = $1`
notifierQueries.clearBlacklistNotification(address) notifierQueries.clearBlacklistNotification(address)
@ -38,10 +38,22 @@ function addToUsedAddresses (address) {
return db.oneOrNone(sql, [address]) return db.oneOrNone(sql, [address])
} }
function getMessages () {
const sql = `SELECT * FROM blacklist_messages`
return db.any(sql)
}
function editBlacklistMessage (id, content) {
const sql = `UPDATE blacklist_messages SET content = $1 WHERE id = $2 RETURNING id`
return db.oneOrNone(sql, [content, id])
}
module.exports = { module.exports = {
blocked, blocked,
addToUsedAddresses, addToUsedAddresses,
getBlacklist, getBlacklist,
deleteFromBlacklist, deleteFromBlacklist,
insertIntoBlacklist insertIntoBlacklist,
getMessages,
editBlacklistMessage
} }

View file

@ -2,13 +2,16 @@ const blacklist = require('../../../blacklist')
const resolvers = { const resolvers = {
Query: { Query: {
blacklist: () => blacklist.getBlacklist() blacklist: () => blacklist.getBlacklist(),
blacklistMessages: () => blacklist.getMessages()
}, },
Mutation: { Mutation: {
deleteBlacklistRow: (...[, { address }]) => deleteBlacklistRow: (...[, { address }]) =>
blacklist.deleteFromBlacklist(address), blacklist.deleteFromBlacklist(address),
insertBlacklistRow: (...[, { address }]) => insertBlacklistRow: (...[, { address }]) =>
blacklist.insertIntoBlacklist(address) blacklist.insertIntoBlacklist(address),
editBlacklistMessage: (...[, { id, content }]) =>
blacklist.editBlacklistMessage(id, content)
} }
} }

View file

@ -3,15 +3,25 @@ const { gql } = require('apollo-server-express')
const typeDef = gql` const typeDef = gql`
type Blacklist { type Blacklist {
address: String! address: String!
blacklistMessage: BlacklistMessage!
}
type BlacklistMessage {
id: ID
label: String
content: String
allowToggle: Boolean
} }
type Query { type Query {
blacklist: [Blacklist] @auth blacklist: [Blacklist] @auth
blacklistMessages: [BlacklistMessage] @auth
} }
type Mutation { type Mutation {
deleteBlacklistRow(address: String!): Blacklist @auth deleteBlacklistRow(address: String!): Blacklist @auth
insertBlacklistRow(address: String!): Blacklist @auth insertBlacklistRow(address: String!): Blacklist @auth
editBlacklistMessage(id: ID, content: String): BlacklistMessage @auth
} }
` `

View file

@ -0,0 +1,26 @@
const uuid = require('uuid')
var db = require('./db')
exports.up = function (next) {
const defaultMessageId = uuid.v4()
var sql = [
`CREATE TABLE blacklist_messages (
id UUID PRIMARY KEY,
label TEXT NOT NULL,
content TEXT NOT NULL,
allow_toggle BOOLEAN NOT NULL DEFAULT true
)`,
`INSERT INTO blacklist_messages (id, label, content, allow_toggle) VALUES ('${defaultMessageId}', 'Suspicious address', 'This address may be associated with a deceptive offer or a prohibited group. Please make sure you''re using an address from your own wallet.', false)`,
`ALTER TABLE blacklist ADD COLUMN blacklist_message_id UUID REFERENCES blacklist_messages(id)`,
`UPDATE blacklist SET blacklist_message_id = '${defaultMessageId}'`,
`ALTER TABLE blacklist ALTER COLUMN blacklist_message_id SET NOT NULL`
]
db.multi(sql, next)
}
exports.down = function (next) {
next()
}

View file

@ -1,7 +1,6 @@
import { useQuery, useMutation } from '@apollo/react-hooks' import { useQuery, useMutation } from '@apollo/react-hooks'
import { addressDetector } from '@lamassu/coins' import { addressDetector } from '@lamassu/coins'
import { Box, Dialog, DialogContent, DialogActions } from '@material-ui/core' import { Box, Dialog, DialogContent, DialogActions } from '@material-ui/core'
import Grid from '@material-ui/core/Grid'
import { makeStyles } from '@material-ui/core/styles' import { makeStyles } from '@material-ui/core/styles'
import gql from 'graphql-tag' import gql from 'graphql-tag'
import * as R from 'ramda' import * as R from 'ramda'
@ -13,9 +12,12 @@ import { Switch } from 'src/components/inputs'
import TitleSection from 'src/components/layout/TitleSection' import TitleSection from 'src/components/layout/TitleSection'
import { H2, Label2, P, Info3, Info2 } from 'src/components/typography' import { H2, Label2, P, Info3, Info2 } from 'src/components/typography'
import { ReactComponent as CloseIcon } from 'src/styling/icons/action/close/zodiac.svg' import { ReactComponent as CloseIcon } from 'src/styling/icons/action/close/zodiac.svg'
import { ReactComponent as ReverseSettingsIcon } from 'src/styling/icons/circle buttons/settings/white.svg'
import { ReactComponent as SettingsIcon } from 'src/styling/icons/circle buttons/settings/zodiac.svg'
import { fromNamespace, toNamespace } from 'src/utils/config' import { fromNamespace, toNamespace } from 'src/utils/config'
import styles from './Blacklist.styles' import styles from './Blacklist.styles'
import BlackListAdvanced from './BlacklistAdvanced'
import BlackListModal from './BlacklistModal' import BlackListModal from './BlacklistModal'
import BlacklistTable from './BlacklistTable' import BlacklistTable from './BlacklistTable'
@ -33,6 +35,11 @@ const GET_BLACKLIST = gql`
query getBlacklistData { query getBlacklistData {
blacklist { blacklist {
address address
blacklistMessage {
id
label
content
}
} }
cryptoCurrencies { cryptoCurrencies {
display display
@ -61,6 +68,25 @@ const ADD_ROW = gql`
} }
` `
const GET_BLACKLIST_MESSAGES = gql`
query getBlacklistMessages {
blacklistMessages {
id
label
content
allowToggle
}
}
`
const EDIT_BLACKLIST_MESSAGE = gql`
mutation editBlacklistMessage($id: ID, $content: String) {
editBlacklistMessage(id: $id, content: $content) {
id
}
}
`
const PaperWalletDialog = ({ onConfirmed, onDissmised, open, props }) => { const PaperWalletDialog = ({ onConfirmed, onDissmised, open, props }) => {
const classes = useStyles() const classes = useStyles()
@ -106,10 +132,13 @@ const PaperWalletDialog = ({ onConfirmed, onDissmised, open, props }) => {
const Blacklist = () => { const Blacklist = () => {
const { data: blacklistResponse } = useQuery(GET_BLACKLIST) const { data: blacklistResponse } = useQuery(GET_BLACKLIST)
const { data: configData } = useQuery(GET_INFO) const { data: configData } = useQuery(GET_INFO)
const { data: messagesResponse, refetch } = useQuery(GET_BLACKLIST_MESSAGES)
const [showModal, setShowModal] = useState(false) const [showModal, setShowModal] = useState(false)
const [errorMsg, setErrorMsg] = useState(null) const [errorMsg, setErrorMsg] = useState(null)
const [editMessageError, setEditMessageError] = useState(null)
const [deleteDialog, setDeleteDialog] = useState(false) const [deleteDialog, setDeleteDialog] = useState(false)
const [confirmDialog, setConfirmDialog] = useState(false) const [confirmDialog, setConfirmDialog] = useState(false)
const [advancedSettings, setAdvancedSettings] = useState(false)
const [deleteEntry] = useMutation(DELETE_ROW, { const [deleteEntry] = useMutation(DELETE_ROW, {
onError: ({ message }) => { onError: ({ message }) => {
@ -129,6 +158,11 @@ const Blacklist = () => {
refetchQueries: () => ['getData'] refetchQueries: () => ['getData']
}) })
const [editMessage] = useMutation(EDIT_BLACKLIST_MESSAGE, {
onError: e => setEditMessageError(e),
refetchQueries: () => ['getBlacklistData']
})
const classes = useStyles() const classes = useStyles()
const blacklistData = R.path(['blacklist'])(blacklistResponse) ?? [] const blacklistData = R.path(['blacklist'])(blacklistResponse) ?? []
@ -184,6 +218,15 @@ const Blacklist = () => {
} }
} }
const editBlacklistMessage = r => {
editMessage({
variables: {
id: r.id,
content: r.content
}
})
}
return ( return (
<> <>
<PaperWalletDialog <PaperWalletDialog
@ -193,62 +236,73 @@ const Blacklist = () => {
setConfirmDialog(false) setConfirmDialog(false)
}} }}
/> />
<TitleSection title="Blacklisted addresses"> <TitleSection
<Box display="flex" alignItems="center" justifyContent="flex-end"> title="Blacklisted addresses"
<Box buttons={[
display="flex" {
alignItems="center" text: 'Advanced settings',
justifyContent="end" icon: SettingsIcon,
mr="15px"> inverseIcon: ReverseSettingsIcon,
<P>Enable paper wallet (only)</P> toggle: setAdvancedSettings
<Switch }
checked={enablePaperWalletOnly} ]}>
onChange={e => {!advancedSettings && (
enablePaperWalletOnly <Box display="flex" alignItems="center" justifyContent="flex-end">
? addressReuseSave({ <Box
enablePaperWalletOnly: e.target.checked display="flex"
}) alignItems="center"
: setConfirmDialog(true) justifyContent="end"
} mr="15px">
value={enablePaperWalletOnly} <P>Enable paper wallet (only)</P>
/> <Switch
<Label2>{enablePaperWalletOnly ? 'On' : 'Off'}</Label2> checked={enablePaperWalletOnly}
<HelpTooltip width={304}> onChange={e =>
<P> enablePaperWalletOnly
The "Enable paper wallet (only)" option means that only paper ? addressReuseSave({
wallets will be printed for users, and they won't be permitted enablePaperWalletOnly: e.target.checked
to scan an address from their own wallet. })
</P> : setConfirmDialog(true)
</HelpTooltip> }
value={enablePaperWalletOnly}
/>
<Label2>{enablePaperWalletOnly ? 'On' : 'Off'}</Label2>
<HelpTooltip width={304}>
<P>
The "Enable paper wallet (only)" option means that only paper
wallets will be printed for users, and they won't be permitted
to scan an address from their own wallet.
</P>
</HelpTooltip>
</Box>
<Box
display="flex"
alignItems="center"
justifyContent="flex-end"
mr="15px">
<P>Reject reused addresses</P>
<Switch
checked={rejectAddressReuse}
onChange={event => {
addressReuseSave({ rejectAddressReuse: event.target.checked })
}}
value={rejectAddressReuse}
/>
<Label2>{rejectAddressReuse ? 'On' : 'Off'}</Label2>
<HelpTooltip width={304}>
<P>
The "Reject reused addresses" option means that all addresses
that are used once will be automatically rejected if there's
an attempt to use them again on a new transaction.
</P>
</HelpTooltip>
</Box>
<Link color="primary" onClick={() => setShowModal(true)}>
Blacklist new addresses
</Link>
</Box> </Box>
<Box )}
display="flex"
alignItems="center"
justifyContent="flex-end"
mr="15px">
<P>Reject reused addresses</P>
<Switch
checked={rejectAddressReuse}
onChange={event => {
addressReuseSave({ rejectAddressReuse: event.target.checked })
}}
value={rejectAddressReuse}
/>
<Label2>{rejectAddressReuse ? 'On' : 'Off'}</Label2>
<HelpTooltip width={304}>
<P>
The "Reject reused addresses" option means that all addresses
that are used once will be automatically rejected if there's an
attempt to use them again on a new transaction.
</P>
</HelpTooltip>
</Box>
<Link color="primary" onClick={() => setShowModal(true)}>
Blacklist new addresses
</Link>
</Box>
</TitleSection> </TitleSection>
<Grid container className={classes.grid}> {!advancedSettings && (
<div className={classes.content}> <div className={classes.content}>
<BlacklistTable <BlacklistTable
data={blacklistData} data={blacklistData}
@ -259,7 +313,15 @@ const Blacklist = () => {
setDeleteDialog={setDeleteDialog} setDeleteDialog={setDeleteDialog}
/> />
</div> </div>
</Grid> )}
{advancedSettings && (
<BlackListAdvanced
data={messagesResponse}
editBlacklistMessage={editBlacklistMessage}
mutationError={editMessageError}
onClose={() => refetch()}
/>
)}
{showModal && ( {showModal && (
<BlackListModal <BlackListModal
onClose={() => { onClose={() => {

View file

@ -9,6 +9,14 @@ const styles = {
flexDirection: 'column', flexDirection: 'column',
flex: 1 flex: 1
}, },
advancedForm: {
'& > *': {
marginTop: 20
},
display: 'flex',
flexDirection: 'column',
height: '100%'
},
footer: { footer: {
display: 'flex', display: 'flex',
flexDirection: 'row', flexDirection: 'row',
@ -58,6 +66,9 @@ const styles = {
cancelButton: { cancelButton: {
marginRight: 8, marginRight: 8,
padding: 0 padding: 0
},
resetToDefault: {
width: 145
} }
} }

View file

@ -0,0 +1,177 @@
import { makeStyles } from '@material-ui/core/styles'
import { Form, Formik, Field } from 'formik'
import * as R from 'ramda'
import React, { useState } from 'react'
import * as Yup from 'yup'
import ErrorMessage from 'src/components/ErrorMessage'
import Modal from 'src/components/Modal'
import { ActionButton, IconButton, Button } from 'src/components/buttons'
import { TextInput } from 'src/components/inputs/formik'
import DataTable from 'src/components/tables/DataTable'
import { ReactComponent as DisabledDeleteIcon } from 'src/styling/icons/action/delete/disabled.svg'
import { ReactComponent as DeleteIcon } from 'src/styling/icons/action/delete/enabled.svg'
import { ReactComponent as EditIcon } from 'src/styling/icons/action/edit/enabled.svg'
import { ReactComponent as DefaultIconReverse } from 'src/styling/icons/button/retry/white.svg'
import { ReactComponent as DefaultIcon } from 'src/styling/icons/button/retry/zodiac.svg'
import styles from './Blacklist.styles'
const useStyles = makeStyles(styles)
const DEFAULT_MESSAGE = `This address may be associated with a deceptive offer or a prohibited group. Please make sure you're using an address from your own wallet.`
const getErrorMsg = (formikErrors, formikTouched, mutationError) => {
if (!formikErrors || !formikTouched) return null
if (mutationError) return 'Internal server error'
if (formikErrors.event && formikTouched.event) return formikErrors.event
if (formikErrors.message && formikTouched.message) return formikErrors.message
return null
}
const BlacklistAdvanced = ({
data,
editBlacklistMessage,
onClose,
mutationError
}) => {
const classes = useStyles()
const [selectedMessage, setSelectedMessage] = useState(null)
const elements = [
{
name: 'label',
header: 'Label',
width: 250,
textAlign: 'left',
size: 'sm',
view: it => R.path(['label'], it)
},
{
name: 'content',
header: 'Content',
width: 690,
textAlign: 'left',
size: 'sm',
view: it => R.path(['content'], it)
},
{
name: 'edit',
header: 'Edit',
width: 130,
textAlign: 'center',
size: 'sm',
view: it => (
<IconButton
className={classes.deleteButton}
onClick={() => setSelectedMessage(it)}>
<EditIcon />
</IconButton>
)
},
{
name: 'deleteButton',
header: 'Delete',
width: 130,
textAlign: 'center',
size: 'sm',
view: it => (
<IconButton
className={classes.deleteButton}
disabled={
!R.isNil(R.path(['allowToggle']) && !R.path(['allowToggle'], it))
}>
{R.path(['allowToggle'], it) ? (
<DeleteIcon />
) : (
<DisabledDeleteIcon />
)}
</IconButton>
)
}
]
const handleModalClose = () => {
setSelectedMessage(null)
}
const handleSubmit = values => {
editBlacklistMessage(values)
handleModalClose()
!R.isNil(onClose) && onClose()
}
const initialValues = {
label: !R.isNil(selectedMessage) ? selectedMessage.label : '',
content: !R.isNil(selectedMessage) ? selectedMessage.content : ''
}
const validationSchema = Yup.object().shape({
label: Yup.string().required('A label is required!'),
content: Yup.string()
.required('The message content is required!')
.trim()
})
return (
<>
<DataTable
data={R.path(['blacklistMessages'], data)}
elements={elements}
emptyText="No blacklisted addresses so far"
name="blacklistTable"
/>
{selectedMessage && (
<Modal
title={`Blacklist message - ${selectedMessage?.label}`}
open={true}
width={676}
height={400}
handleClose={handleModalClose}>
<Formik
validateOnBlur={false}
validateOnChange={false}
initialValues={initialValues}
validationSchema={validationSchema}
onSubmit={values =>
handleSubmit({ id: selectedMessage.id, ...values })
}>
{({ errors, touched, setFieldValue }) => (
<Form className={classes.advancedForm}>
<ActionButton
color="primary"
Icon={DefaultIcon}
InverseIcon={DefaultIconReverse}
className={classes.resetToDefault}
type="button"
onClick={() => setFieldValue('content', DEFAULT_MESSAGE)}>
Reset to default
</ActionButton>
<Field
name="content"
label="Message content"
fullWidth
multiline={true}
rows={6}
component={TextInput}
/>
<div className={classes.footer}>
{getErrorMsg(errors, touched, mutationError) && (
<ErrorMessage>
{getErrorMsg(errors, touched, mutationError)}
</ErrorMessage>
)}
<Button type="submit" className={classes.submit}>
Confirm
</Button>
</div>
</Form>
)}
</Formik>
</Modal>
)}
</>
)
}
export default BlacklistAdvanced