Feature #180: Predict transaction nonce (#293)

* Dep bump

* Fetch transactions when safe view is mounted

* eslint fix

* Calculate new tx nonce from latest tx in service

* Fix tx cancellation, allow passing nonce to createTransaction

* dep bump

* Refactor createTransaction/processTransaction to use object as argument

* Adopting transactions table to new send tx flow with predicted nonces

* dep bump, disable esModule in file-loader options after new v5 release

* Don't show older tx annotation for already executed txs

* sort tx by nonce

* get new safe nonce after tx execution

* Bugfixes

* remove whitespace for showOlderTxAnnotation
This commit is contained in:
Mikhail Mikheev 2019-12-12 12:30:27 +04:00 committed by GitHub
parent 87a7796a84
commit bc7d5836f6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 543 additions and 2569 deletions

View File

@ -3,7 +3,6 @@
<PROJECT_ROOT>/contracts/**/.* <PROJECT_ROOT>/contracts/**/.*
<PROJECT_ROOT>/scripts/**/.* <PROJECT_ROOT>/scripts/**/.*
<PROJECT_ROOT>/public/**/.* <PROJECT_ROOT>/public/**/.*
<PROJECT_ROOT>/src/test/**/.*
<PROJECT_ROOT>/babel.config.js <PROJECT_ROOT>/babel.config.js
<PROJECT_ROOT>/jest.config.js <PROJECT_ROOT>/jest.config.js
<PROJECT_ROOT>/truffle.js <PROJECT_ROOT>/truffle.js

View File

@ -13,7 +13,7 @@ type Props = {
margin?: Size, margin?: Size,
padding?: Size, padding?: Size,
justify?: 'center' | 'right' | 'left' | 'space-around', justify?: 'center' | 'right' | 'left' | 'space-around',
children: React.Node, children?: React.Node,
className?: string, className?: string,
} }

View File

@ -2,12 +2,12 @@
display: flex; display: flex;
flex: 1 0 auto; flex: 1 0 auto;
flex-direction: column; flex-direction: column;
padding: 80px 200px 0px 200px; padding: 135px 200px 0px 200px;
} }
@media only screen and (max-width: $(screenLg)px) { @media only screen and (max-width: $(screenLg)px) {
.page { .page {
padding: 80px $lg 0px $lg; padding: 135px $lg 0px $lg;
} }
} }

View File

@ -79,15 +79,15 @@ const ReviewCustomTx = ({
const txData = tx.data.trim() const txData = tx.data.trim()
const txValue = tx.value ? web3.utils.toWei(tx.value, 'ether') : 0 const txValue = tx.value ? web3.utils.toWei(tx.value, 'ether') : 0
createTransaction( createTransaction({
safeAddress, safeAddress,
txRecipient, to: txRecipient,
txValue, valueInWei: txValue,
txData, txData,
TX_NOTIFICATION_TYPES.STANDARD_TX, notifiedTransaction: TX_NOTIFICATION_TYPES.STANDARD_TX,
enqueueSnackbar, enqueueSnackbar,
closeSnackbar, closeSnackbar,
) })
onClose() onClose()
} }

View File

@ -109,15 +109,15 @@ const ReviewTx = ({
txAmount = 0 txAmount = 0
} }
createTransaction( createTransaction({
safeAddress, safeAddress,
txRecipient, to: txRecipient,
txAmount, valueInWei: txAmount,
txData, txData,
TX_NOTIFICATION_TYPES.STANDARD_TX, notifiedTransaction: TX_NOTIFICATION_TYPES.STANDARD_TX,
enqueueSnackbar, enqueueSnackbar,
closeSnackbar, closeSnackbar,
) })
onClose() onClose()
} }

View File

@ -176,6 +176,7 @@ const Layout = (props: Props) => {
<Transactions <Transactions
threshold={safe.threshold} threshold={safe.threshold}
owners={safe.owners} owners={safe.owners}
nonce={safe.nonce}
transactions={transactions} transactions={transactions}
fetchTransactions={fetchTransactions} fetchTransactions={fetchTransactions}
safeAddress={address} safeAddress={address}

View File

@ -47,15 +47,15 @@ export const sendAddOwner = async (
const gnosisSafe = await getGnosisSafeInstanceAt(safeAddress) const gnosisSafe = await getGnosisSafeInstanceAt(safeAddress)
const txData = gnosisSafe.contract.methods.addOwnerWithThreshold(values.ownerAddress, values.threshold).encodeABI() const txData = gnosisSafe.contract.methods.addOwnerWithThreshold(values.ownerAddress, values.threshold).encodeABI()
const txHash = await createTransaction( const txHash = await createTransaction({
safeAddress, safeAddress,
safeAddress, to: safeAddress,
0, valueInWei: 0,
txData, txData,
TX_NOTIFICATION_TYPES.SETTINGS_CHANGE_TX, notifiedTransaction: TX_NOTIFICATION_TYPES.SETTINGS_CHANGE_TX,
enqueueSnackbar, enqueueSnackbar,
closeSnackbar, closeSnackbar,
) })
if (txHash) { if (txHash) {
addSafeOwner({ safeAddress, ownerName: values.ownerName, ownerAddress: values.ownerAddress }) addSafeOwner({ safeAddress, ownerName: values.ownerName, ownerAddress: values.ownerAddress })

View File

@ -62,15 +62,15 @@ export const sendRemoveOwner = async (
.removeOwner(prevAddress, ownerAddressToRemove, values.threshold) .removeOwner(prevAddress, ownerAddressToRemove, values.threshold)
.encodeABI() .encodeABI()
const txHash = await createTransaction( const txHash = await createTransaction({
safeAddress, safeAddress,
safeAddress, to: safeAddress,
0, valueInWei: 0,
txData, txData,
TX_NOTIFICATION_TYPES.SETTINGS_CHANGE_TX, notifiedTransaction: TX_NOTIFICATION_TYPES.SETTINGS_CHANGE_TX,
enqueueSnackbar, enqueueSnackbar,
closeSnackbar, closeSnackbar,
) })
if (txHash && safe.threshold === 1) { if (txHash && safe.threshold === 1) {
removeSafeOwner({ safeAddress, ownerAddress: ownerAddressToRemove }) removeSafeOwner({ safeAddress, ownerAddress: ownerAddressToRemove })

View File

@ -58,15 +58,15 @@ export const sendReplaceOwner = async (
.swapOwner(prevAddress, ownerAddressToRemove, values.ownerAddress) .swapOwner(prevAddress, ownerAddressToRemove, values.ownerAddress)
.encodeABI() .encodeABI()
const txHash = await createTransaction( const txHash = await createTransaction({
safeAddress, safeAddress,
safeAddress, to: safeAddress,
0, valueInWei: 0,
txData, txData,
TX_NOTIFICATION_TYPES.SETTINGS_CHANGE_TX, notifiedTransaction: TX_NOTIFICATION_TYPES.SETTINGS_CHANGE_TX,
enqueueSnackbar, enqueueSnackbar,
closeSnackbar, closeSnackbar,
) })
if (txHash && safe.threshold === 1) { if (txHash && safe.threshold === 1) {
replaceSafeOwner({ replaceSafeOwner({

View File

@ -47,15 +47,15 @@ const ThresholdSettings = ({
const safeInstance = await getGnosisSafeInstanceAt(safeAddress) const safeInstance = await getGnosisSafeInstanceAt(safeAddress)
const txData = safeInstance.contract.methods.changeThreshold(newThreshold).encodeABI() const txData = safeInstance.contract.methods.changeThreshold(newThreshold).encodeABI()
createTransaction( createTransaction({
safeAddress, safeAddress,
safeAddress, to: safeAddress,
0, valueInWei: 0,
txData, txData,
TX_NOTIFICATION_TYPES.SETTINGS_CHANGE_TX, notifiedTransaction: TX_NOTIFICATION_TYPES.SETTINGS_CHANGE_TX,
enqueueSnackbar, enqueueSnackbar,
closeSnackbar, closeSnackbar,
) })
} }
return ( return (

View File

@ -33,8 +33,9 @@ type Props = {
threshold: number, threshold: number,
thresholdReached: boolean, thresholdReached: boolean,
userAddress: string, userAddress: string,
canExecute: boolean,
enqueueSnackbar: Function, enqueueSnackbar: Function,
closeSnackbar: Function, closeSnackbar: Function
} }
const getModalTitleAndDescription = (thresholdReached: boolean) => { const getModalTitleAndDescription = (thresholdReached: boolean) => {
@ -59,15 +60,16 @@ const ApproveTxModal = ({
tx, tx,
safeAddress, safeAddress,
threshold, threshold,
canExecute,
thresholdReached, thresholdReached,
userAddress, userAddress,
enqueueSnackbar, enqueueSnackbar,
closeSnackbar, closeSnackbar,
}: Props) => { }: Props) => {
const oneConfirmationLeft = !thresholdReached && tx.confirmations.size + 1 === threshold const [approveAndExecute, setApproveAndExecute] = useState<boolean>(canExecute)
const [approveAndExecute, setApproveAndExecute] = useState<boolean>(oneConfirmationLeft || thresholdReached)
const [gasCosts, setGasCosts] = useState<string>('< 0.001') const [gasCosts, setGasCosts] = useState<string>('< 0.001')
const { title, description } = getModalTitleAndDescription(thresholdReached) const { title, description } = getModalTitleAndDescription(thresholdReached)
const oneConfirmationLeft = !thresholdReached && tx.confirmations.size + 1 === threshold
useEffect(() => { useEffect(() => {
let isCurrent = true let isCurrent = true
@ -100,20 +102,25 @@ const ApproveTxModal = ({
const handleExecuteCheckbox = () => setApproveAndExecute((prevApproveAndExecute) => !prevApproveAndExecute) const handleExecuteCheckbox = () => setApproveAndExecute((prevApproveAndExecute) => !prevApproveAndExecute)
const approveTx = () => { const approveTx = () => {
processTransaction( processTransaction({
safeAddress, safeAddress,
tx, tx,
userAddress, userAddress,
TX_NOTIFICATION_TYPES.CONFIRMATION_TX, notifiedTransaction: TX_NOTIFICATION_TYPES.CONFIRMATION_TX,
enqueueSnackbar, enqueueSnackbar,
closeSnackbar, closeSnackbar,
approveAndExecute && oneConfirmationLeft, approveAndExecute: canExecute && approveAndExecute && oneConfirmationLeft,
) })
onClose() onClose()
} }
return ( return (
<Modal title={title} description={description} handleClose={onClose} open={isOpen}> <Modal
title={title}
description={description}
handleClose={onClose}
open={isOpen}
>
<Row align="center" grow className={classes.heading}> <Row align="center" grow className={classes.heading}>
<Paragraph weight="bolder" className={classes.headingText} noMargin> <Paragraph weight="bolder" className={classes.headingText} noMargin>
{title} {title}
@ -131,14 +138,21 @@ const ApproveTxModal = ({
<br /> <br />
<Bold className={classes.nonceNumber}>{tx.nonce}</Bold> <Bold className={classes.nonceNumber}>{tx.nonce}</Bold>
</Paragraph> </Paragraph>
{oneConfirmationLeft && ( {oneConfirmationLeft && canExecute && (
<> <>
<Paragraph color="error"> <Paragraph color="error">
Approving this transaction executes it right away. If you want approve but execute the transaction Approving this transaction executes it right away. If you want
manually later, click on the checkbox below. approve but execute the transaction manually later, click on the
checkbox below.
</Paragraph> </Paragraph>
<FormControlLabel <FormControlLabel
control={<Checkbox onChange={handleExecuteCheckbox} checked={approveAndExecute} color="primary" />} control={(
<Checkbox
onChange={handleExecuteCheckbox}
checked={approveAndExecute}
color="primary"
/>
)}
label="Execute transaction" label="Execute transaction"
/> />
</> </>

View File

@ -64,15 +64,15 @@ const CancelTxModal = ({
}, []) }, [])
const sendReplacementTransaction = () => { const sendReplacementTransaction = () => {
createTransaction( createTransaction({
safeAddress, safeAddress,
safeAddress, to: safeAddress,
0, valueInWei: 0,
EMPTY_DATA, notifiedTransaction: TX_NOTIFICATION_TYPES.CANCELLATION_TX,
TX_NOTIFICATION_TYPES.CANCELLATION_TX,
enqueueSnackbar, enqueueSnackbar,
closeSnackbar, closeSnackbar,
) txNonce: tx.nonce,
})
onClose() onClose()
} }

View File

@ -33,7 +33,7 @@ const ButtonRow = ({
onTxCancel, onTxCancel,
showCancelBtn, showCancelBtn,
}: Props) => ( }: Props) => (
<Row align="right" className={classes.buttonRow}> <Row align="end" className={classes.buttonRow}>
{showCancelBtn && ( {showCancelBtn && (
<Button className={classes.button} variant="contained" minWidth={140} color="secondary" onClick={onTxCancel}> <Button className={classes.button} variant="contained" minWidth={140} color="secondary" onClick={onTxCancel}>
Cancel tx Cancel tx

View File

@ -26,7 +26,7 @@ type OwnerProps = {
showConfirmBtn: boolean, showConfirmBtn: boolean,
showExecuteBtn: boolean, showExecuteBtn: boolean,
onTxConfirm: Function, onTxConfirm: Function,
onTxExecute: Function, onTxExecute: Function
} }
const OwnerComponent = ({ const OwnerComponent = ({
@ -42,21 +42,33 @@ const OwnerComponent = ({
thresholdReached, thresholdReached,
}: OwnerProps) => ( }: OwnerProps) => (
<Block className={classes.container}> <Block className={classes.container}>
<div className={confirmed || thresholdReached || executor <div
? classes.verticalLineProgressDone className={
: classes.verticalLineProgressPending} confirmed || thresholdReached || executor
? classes.verticalLineProgressDone
: classes.verticalLineProgressPending
}
/> />
<div className={classes.iconState}> <div className={classes.iconState}>
{confirmed {confirmed ? (
? <Img src={ConfirmSmallFilledIcon} /> <Img src={ConfirmSmallFilledIcon} />
: thresholdReached || executor ? <Img src={ConfirmSmallGreenIcon} /> : <Img src={ConfirmSmallGreyIcon} />} ) : thresholdReached || executor ? (
<Img src={ConfirmSmallGreenIcon} />
) : (
<Img src={ConfirmSmallGreyIcon} />
)}
</div> </div>
<Identicon address={owner.address} diameter={32} className={classes.icon} /> <Identicon address={owner.address} diameter={32} className={classes.icon} />
<Block> <Block>
<Paragraph className={classes.name} noMargin> <Paragraph className={classes.name} noMargin>
{owner.name} {owner.name}
</Paragraph> </Paragraph>
<EtherscanLink className={classes.address} type="address" value={owner.address} cut={4} /> <EtherscanLink
className={classes.address}
type="address"
value={owner.address}
cut={4}
/>
</Block> </Block>
<Block className={classes.spacer} /> <Block className={classes.spacer} />
{showConfirmBtn && owner.address === userAddress && ( {showConfirmBtn && owner.address === userAddress && (

View File

@ -16,6 +16,7 @@ import CheckLargeFilledGreenIcon from './assets/check-large-filled-green.svg'
import ConfirmLargeGreenIcon from './assets/confirm-large-green.svg' import ConfirmLargeGreenIcon from './assets/confirm-large-green.svg'
import ConfirmLargeGreyIcon from './assets/confirm-large-grey.svg' import ConfirmLargeGreyIcon from './assets/confirm-large-grey.svg'
import { styles } from './style' import { styles } from './style'
import Paragraph from '~/components/layout/Paragraph/index'
type Props = { type Props = {
tx: Transaction, tx: Transaction,
@ -26,15 +27,13 @@ type Props = {
userAddress: string, userAddress: string,
thresholdReached: boolean, thresholdReached: boolean,
safeAddress: string, safeAddress: string,
canExecute: boolean,
onTxConfirm: Function, onTxConfirm: Function,
onTxCancel: Function, onTxCancel: Function,
onTxExecute: Function, onTxExecute: Function
} }
const isCancellationTransaction = ( const isCancellationTransaction = (tx: Transaction, safeAddress: string) => !tx.value && tx.data === EMPTY_DATA && tx.recipient === safeAddress
tx: Transaction,
safeAddress: string,
) => !tx.value && tx.data === EMPTY_DATA && tx.recipient === safeAddress
const OwnersColumn = ({ const OwnersColumn = ({
tx, tx,
@ -48,8 +47,10 @@ const OwnersColumn = ({
onTxConfirm, onTxConfirm,
onTxCancel, onTxCancel,
onTxExecute, onTxExecute,
canExecute,
}: Props) => { }: Props) => {
const cancellationTx = isCancellationTransaction(tx, safeAddress) const cancellationTx = isCancellationTransaction(tx, safeAddress)
const showOlderTxAnnotation = thresholdReached && !canExecute && !tx.isExecuted
const ownersWhoConfirmed = [] const ownersWhoConfirmed = []
let currentUserAlreadyConfirmed = false let currentUserAlreadyConfirmed = false
@ -62,7 +63,9 @@ const OwnersColumn = ({
} }
}) })
const ownersUnconfirmed = owners.filter( const ownersUnconfirmed = owners.filter(
(owner) => tx.confirmations.findIndex((conf) => conf.owner.address === owner.address) === -1, (owner) => tx.confirmations.findIndex(
(conf) => conf.owner.address === owner.address,
) === -1,
) )
let userIsUnconfirmedOwner let userIsUnconfirmedOwner
ownersUnconfirmed.some((owner) => { ownersUnconfirmed.some((owner) => {
@ -77,25 +80,39 @@ const OwnersColumn = ({
} else if (tx.status === 'cancelled') { } else if (tx.status === 'cancelled') {
// tx is cancelled (replaced) by another one // tx is cancelled (replaced) by another one
displayButtonRow = false displayButtonRow = false
} else if (cancellationTx && currentUserAlreadyConfirmed && !thresholdReached) { } else if (
cancellationTx
&& currentUserAlreadyConfirmed
&& !thresholdReached
) {
// the TX is the cancellation (replacement) transaction for previous TX, // the TX is the cancellation (replacement) transaction for previous TX,
// current user has already confirmed it and threshold is not reached (so he can't execute/cancel it) // current user has already confirmed it and threshold is not reached (so he can't execute/cancel it)
displayButtonRow = false displayButtonRow = false
} }
const showConfirmBtn = !tx.isExecuted const showConfirmBtn = !tx.isExecuted
&& tx.status !== 'pending'
&& !tx.cancelled && !tx.cancelled
&& userIsUnconfirmedOwner && userIsUnconfirmedOwner
&& !currentUserAlreadyConfirmed && !currentUserAlreadyConfirmed
&& !thresholdReached && !thresholdReached
const showExecuteBtn = canExecute && !tx.isExecuted && thresholdReached
return ( return (
<Col xs={6} className={classes.rightCol} layout="block"> <Col xs={6} className={classes.rightCol} layout="block">
<Block className={cn(classes.ownerListTitle, (thresholdReached || tx.isExecuted) && classes.ownerListTitleDone)}> <Block
className={cn(
classes.ownerListTitle,
(thresholdReached || tx.isExecuted) && classes.ownerListTitleDone,
)}
>
<div className={classes.iconState}> <div className={classes.iconState}>
{thresholdReached || tx.isExecuted {thresholdReached || tx.isExecuted ? (
? <Img src={CheckLargeFilledGreenIcon} /> <Img src={CheckLargeFilledGreenIcon} />
: <Img src={ConfirmLargeGreenIcon} />} ) : (
<Img src={ConfirmLargeGreenIcon} />
)}
</div> </div>
{tx.isExecuted {tx.isExecuted
? `Confirmed [${tx.confirmations.size}/${tx.confirmations.size}]` ? `Confirmed [${tx.confirmations.size}/${tx.confirmations.size}]`
@ -110,25 +127,43 @@ const OwnersColumn = ({
onTxConfirm={onTxConfirm} onTxConfirm={onTxConfirm}
onTxExecute={onTxExecute} onTxExecute={onTxExecute}
showConfirmBtn={showConfirmBtn} showConfirmBtn={showConfirmBtn}
showExecuteBtn={!tx.isExecuted && thresholdReached} showExecuteBtn={showExecuteBtn}
/> />
<Block className={cn(classes.ownerListTitle, tx.isExecuted && classes.ownerListTitleDone)}> <Block
<div className={thresholdReached || tx.isExecuted className={cn(
? classes.verticalLineProgressDone classes.ownerListTitle,
: classes.verticalLineProgressPending} tx.isExecuted && classes.ownerListTitleDone,
)}
>
<div
className={
thresholdReached || tx.isExecuted
? classes.verticalLineProgressDone
: classes.verticalLineProgressPending
}
/> />
<div className={classes.iconState}> <div className={classes.iconState}>
{!thresholdReached && !tx.isExecuted && <Img src={ConfirmLargeGreyIcon} />} {!thresholdReached && !tx.isExecuted && (
{thresholdReached && !tx.isExecuted && <Img src={ConfirmLargeGreenIcon} />} <Img src={ConfirmLargeGreyIcon} alt="Confirm tx" />
{tx.isExecuted && <Img src={CheckLargeFilledGreenIcon} />} )}
{thresholdReached && !tx.isExecuted && (
<Img src={ConfirmLargeGreenIcon} alt="Execute tx" />
)}
{tx.isExecuted && (
<Img src={CheckLargeFilledGreenIcon} alt="TX Executed icon" />
)}
</div> </div>
Executed Executed
</Block> </Block>
{showOlderTxAnnotation && (
<Block className={classes.olderTxAnnotation}>
<Paragraph>
There are older transactions that need to be executed first
</Paragraph>
</Block>
)}
{granted && displayButtonRow && ( {granted && displayButtonRow && (
<ButtonRow <ButtonRow onTxCancel={onTxCancel} showCancelBtn={!cancellationTx} />
onTxCancel={onTxCancel}
showCancelBtn={!cancellationTx}
/>
)} )}
</Col> </Col>
) )

View File

@ -53,6 +53,9 @@ export const styles = () => ({
textTransform: 'uppercase', textTransform: 'uppercase',
letterSpacing: '1px', letterSpacing: '1px',
}, },
olderTxAnnotation: {
textAlign: 'center',
},
ownerListTitleDone: { ownerListTitleDone: {
color: secondary, color: secondary,
}, },

View File

@ -29,6 +29,7 @@ type Props = {
safeAddress: string, safeAddress: string,
createTransaction: Function, createTransaction: Function,
processTransaction: Function, processTransaction: Function,
nonce: number,
} }
type OpenModal = 'cancelTx' | 'approveTx' | null type OpenModal = 'cancelTx' | 'approveTx' | null
@ -52,12 +53,14 @@ const ExpandedTx = ({
safeAddress, safeAddress,
createTransaction, createTransaction,
processTransaction, processTransaction,
nonce,
}: Props) => { }: Props) => {
const [openModal, setOpenModal] = useState<OpenModal>(null) const [openModal, setOpenModal] = useState<OpenModal>(null)
const openApproveModal = () => setOpenModal('approveTx') const openApproveModal = () => setOpenModal('approveTx')
const openCancelModal = () => setOpenModal('cancelTx') const openCancelModal = () => setOpenModal('cancelTx')
const closeModal = () => setOpenModal(null) const closeModal = () => setOpenModal(null)
const thresholdReached = threshold <= tx.confirmations.size const thresholdReached = threshold <= tx.confirmations.size
const canExecute = nonce === tx.nonce
return ( return (
<> <>
@ -117,6 +120,7 @@ const ExpandedTx = ({
tx={tx} tx={tx}
owners={owners} owners={owners}
granted={granted} granted={granted}
canExecute={canExecute}
threshold={threshold} threshold={threshold}
userAddress={userAddress} userAddress={userAddress}
thresholdReached={thresholdReached} thresholdReached={thresholdReached}
@ -141,6 +145,7 @@ const ExpandedTx = ({
isOpen isOpen
processTransaction={processTransaction} processTransaction={processTransaction}
onClose={closeModal} onClose={closeModal}
canExecute={canExecute}
tx={tx} tx={tx}
userAddress={userAddress} userAddress={userAddress}
safeAddress={safeAddress} safeAddress={safeAddress}

View File

@ -17,7 +17,7 @@ import { type Transaction } from '~/routes/safe/store/models/transaction'
import { type Owner } from '~/routes/safe/store/models/owner' import { type Owner } from '~/routes/safe/store/models/owner'
import ExpandedTxComponent from './ExpandedTx' import ExpandedTxComponent from './ExpandedTx'
import { import {
getTxTableData, generateColumns, TX_TABLE_DATE_ID, type TransactionRow, TX_TABLE_RAW_TX_ID, getTxTableData, generateColumns, TX_TABLE_NONCE_ID, type TransactionRow, TX_TABLE_RAW_TX_ID,
} from './columns' } from './columns'
import { styles } from './style' import { styles } from './style'
import Status from './Status' import Status from './Status'
@ -37,6 +37,7 @@ type Props = {
userAddress: string, userAddress: string,
granted: boolean, granted: boolean,
safeAddress: string, safeAddress: string,
nonce: number,
createTransaction: Function, createTransaction: Function,
processTransaction: Function, processTransaction: Function,
} }
@ -51,6 +52,7 @@ const TxsTable = ({
safeAddress, safeAddress,
createTransaction, createTransaction,
processTransaction, processTransaction,
nonce,
}: Props) => { }: Props) => {
const [expandedTx, setExpandedTx] = useState<string | null>(null) const [expandedTx, setExpandedTx] = useState<string | null>(null)
@ -66,7 +68,7 @@ const TxsTable = ({
<Block className={classes.container}> <Block className={classes.container}>
<Table <Table
label="Transactions" label="Transactions"
defaultOrderBy={TX_TABLE_DATE_ID} defaultOrderBy={TX_TABLE_NONCE_ID}
defaultOrder="desc" defaultOrder="desc"
defaultRowsPerPage={25} defaultRowsPerPage={25}
columns={columns} columns={columns}
@ -126,6 +128,7 @@ const TxsTable = ({
createTransaction={createTransaction} createTransaction={createTransaction}
processTransaction={processTransaction} processTransaction={processTransaction}
safeAddress={safeAddress} safeAddress={safeAddress}
nonce={nonce}
/> />
</TableCell> </TableCell>
</TableRow> </TableRow>

View File

@ -1,5 +1,5 @@
// @flow // @flow
import React from 'react' import React, { useEffect } from 'react'
import { List } from 'immutable' import { List } from 'immutable'
import TxsTable from '~/routes/safe/components/Transactions/TxsTable' import TxsTable from '~/routes/safe/components/Transactions/TxsTable'
import { type Transaction } from '~/routes/safe/store/models/transaction' import { type Transaction } from '~/routes/safe/store/models/transaction'
@ -14,9 +14,13 @@ type Props = {
granted: boolean, granted: boolean,
createTransaction: Function, createTransaction: Function,
processTransaction: Function, processTransaction: Function,
fetchTransactions: Function,
currentNetwork: string, currentNetwork: string,
nonce: number,
} }
const TIMEOUT = 5000
const Transactions = ({ const Transactions = ({
transactions = List(), transactions = List(),
owners, owners,
@ -26,8 +30,23 @@ const Transactions = ({
safeAddress, safeAddress,
createTransaction, createTransaction,
processTransaction, processTransaction,
fetchTransactions,
currentNetwork, currentNetwork,
}: Props) => ( nonce,
}: Props) => {
let intervalId: IntervalID
useEffect(() => {
fetchTransactions(safeAddress)
intervalId = setInterval(() => {
fetchTransactions(safeAddress)
}, TIMEOUT)
return () => clearInterval(intervalId)
}, [safeAddress])
return (
<TxsTable <TxsTable
transactions={transactions} transactions={transactions}
threshold={threshold} threshold={threshold}
@ -38,7 +57,9 @@ const Transactions = ({
safeAddress={safeAddress} safeAddress={safeAddress}
createTransaction={createTransaction} createTransaction={createTransaction}
processTransaction={processTransaction} processTransaction={processTransaction}
nonce={nonce}
/> />
) )
}
export default Transactions export default Transactions

View File

@ -18,7 +18,8 @@ export type Actions = {
fetchTokens: typeof fetchTokens, fetchTokens: typeof fetchTokens,
processTransaction: typeof processTransaction, processTransaction: typeof processTransaction,
fetchEtherBalance: typeof fetchEtherBalance, fetchEtherBalance: typeof fetchEtherBalance,
activateTokensByBalance: typeof activateTokensByBalance activateTokensByBalance: typeof activateTokensByBalance,
checkAndUpdateSafeOwners: typeof checkAndUpdateSafe
} }
export default { export default {

View File

@ -34,14 +34,12 @@ class SafeView extends React.Component<Props, State> {
componentDidMount() { componentDidMount() {
const { const {
fetchSafe, activeTokens, safeUrl, fetchTokenBalances, fetchTokens, fetchTransactions, safe, fetchSafe, activeTokens, safeUrl, fetchTokenBalances, fetchTokens, fetchTransactions,
} = this.props } = this.props
fetchSafe(safeUrl) fetchSafe(safeUrl)
fetchTokenBalances(safeUrl, activeTokens) fetchTokenBalances(safeUrl, activeTokens)
if (safe && safe.address) { fetchTransactions(safeUrl)
fetchTransactions(safe.address)
}
// fetch tokens there to get symbols for tokens in TXs list // fetch tokens there to get symbols for tokens in TXs list
fetchTokens() fetchTokens()
@ -52,12 +50,16 @@ class SafeView extends React.Component<Props, State> {
} }
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
const { activeTokens } = this.props const { activeTokens, safeUrl, fetchTransactions } = this.props
const oldActiveTokensSize = prevProps.activeTokens.size const oldActiveTokensSize = prevProps.activeTokens.size
if (oldActiveTokensSize > 0 && activeTokens.size > oldActiveTokensSize) { if (oldActiveTokensSize > 0 && activeTokens.size > oldActiveTokensSize) {
this.checkForUpdates() this.checkForUpdates()
} }
if (safeUrl !== prevProps.safeUrl) {
fetchTransactions(safeUrl)
}
} }
componentWillUnmount() { componentWillUnmount() {
@ -92,7 +94,11 @@ class SafeView extends React.Component<Props, State> {
checkForUpdates() { checkForUpdates() {
const { const {
safeUrl, activeTokens, fetchTokenBalances, fetchEtherBalance, checkAndUpdateSafeOwners, safeUrl,
activeTokens,
fetchTokenBalances,
fetchEtherBalance,
checkAndUpdateSafeOwners,
} = this.props } = this.props
checkAndUpdateSafeOwners(safeUrl) checkAndUpdateSafeOwners(safeUrl)
fetchTokenBalances(safeUrl, activeTokens) fetchTokenBalances(safeUrl, activeTokens)

View File

@ -1,10 +1,12 @@
// @flow // @flow
import axios from 'axios'
import type { Dispatch as ReduxDispatch, GetState } from 'redux' import type { Dispatch as ReduxDispatch, GetState } from 'redux'
import { push } from 'connected-react-router' import { push } from 'connected-react-router'
import { EMPTY_DATA } from '~/logic/wallets/ethTransactions' import { EMPTY_DATA } from '~/logic/wallets/ethTransactions'
import { userAccountSelector } from '~/logic/wallets/store/selectors' import { userAccountSelector } from '~/logic/wallets/store/selectors'
import fetchTransactions from '~/routes/safe/store/actions/fetchTransactions' import fetchTransactions from '~/routes/safe/store/actions/fetchTransactions'
import { type GlobalState } from '~/store' import { type GlobalState } from '~/store'
import { buildTxServiceUrl } from '~/logic/safe/transactions/txHistory'
import { getGnosisSafeInstanceAt } from '~/logic/contracts/safeContracts' import { getGnosisSafeInstanceAt } from '~/logic/contracts/safeContracts'
import { import {
getApprovalTransaction, getApprovalTransaction,
@ -15,29 +17,68 @@ import {
TX_TYPE_EXECUTION, TX_TYPE_EXECUTION,
saveTxToHistory, saveTxToHistory,
} from '~/logic/safe/transactions' } from '~/logic/safe/transactions'
import { type NotificationsQueue, getNotificationsFromTxType, showSnackbar } from '~/logic/notifications' import {
type NotificationsQueue,
getNotificationsFromTxType,
showSnackbar,
} from '~/logic/notifications'
import { getErrorMessage } from '~/test/utils/ethereumErrors' import { getErrorMessage } from '~/test/utils/ethereumErrors'
import { ZERO_ADDRESS } from '~/logic/wallets/ethAddresses' import { ZERO_ADDRESS } from '~/logic/wallets/ethAddresses'
import { SAFELIST_ADDRESS } from '~/routes/routes' import { SAFELIST_ADDRESS } from '~/routes/routes'
const createTransaction = ( const getLastPendingTxNonce = async (safeAddress: string): Promise<number> => {
let nonce
try {
const url = buildTxServiceUrl(safeAddress)
const response = await axios.get(url, { params: { limit: 1 } })
const lastTx = response.data.results[0]
nonce = lastTx.nonce + 1
} catch (err) {
// use current's safe nonce as fallback
const safeInstance = await getGnosisSafeInstanceAt(safeAddress)
nonce = (await safeInstance.nonce()).toString()
}
return nonce
}
type CreateTransactionArgs = {
safeAddress: string, safeAddress: string,
to: string, to: string,
valueInWei: string, valueInWei: string,
txData: string = EMPTY_DATA, txData: string,
notifiedTransaction: NotifiedTransaction, notifiedTransaction: NotifiedTransaction,
enqueueSnackbar: Function, enqueueSnackbar: Function,
closeSnackbar: Function, closeSnackbar: Function,
shouldExecute?: boolean, shouldExecute?: boolean,
) => async (dispatch: ReduxDispatch<GlobalState>, getState: GetState<GlobalState>) => { txNonce?: number,
}
const createTransaction = ({
safeAddress,
to,
valueInWei,
txData = EMPTY_DATA,
notifiedTransaction,
enqueueSnackbar,
closeSnackbar,
shouldExecute = false,
txNonce,
}: CreateTransactionArgs) => async (
dispatch: ReduxDispatch<GlobalState>,
getState: GetState<GlobalState>,
) => {
const state: GlobalState = getState() const state: GlobalState = getState()
dispatch(push(`${SAFELIST_ADDRESS}/${safeAddress}/transactions`)) dispatch(push(`${SAFELIST_ADDRESS}/${safeAddress}/transactions`))
const safeInstance = await getGnosisSafeInstanceAt(safeAddress)
const from = userAccountSelector(state) const from = userAccountSelector(state)
const safeInstance = await getGnosisSafeInstanceAt(safeAddress)
const threshold = await safeInstance.getThreshold() const threshold = await safeInstance.getThreshold()
const nonce = (await safeInstance.nonce()).toString() const nonce = txNonce || await getLastPendingTxNonce(safeAddress)
const isExecution = threshold.toNumber() === 1 || shouldExecute const isExecution = threshold.toNumber() === 1 || shouldExecute
// https://gnosis-safe.readthedocs.io/en/latest/contracts/signatures.html#pre-validated-signatures // https://gnosis-safe.readthedocs.io/en/latest/contracts/signatures.html#pre-validated-signatures
@ -46,8 +87,14 @@ const createTransaction = (
'', '',
)}000000000000000000000000000000000000000000000000000000000000000001` )}000000000000000000000000000000000000000000000000000000000000000001`
const notificationsQueue: NotificationsQueue = getNotificationsFromTxType(notifiedTransaction) const notificationsQueue: NotificationsQueue = getNotificationsFromTxType(
const beforeExecutionKey = showSnackbar(notificationsQueue.beforeExecution, enqueueSnackbar, closeSnackbar) notifiedTransaction,
)
const beforeExecutionKey = showSnackbar(
notificationsQueue.beforeExecution,
enqueueSnackbar,
closeSnackbar,
)
let pendingExecutionKey let pendingExecutionKey
let txHash let txHash
@ -99,7 +146,11 @@ const createTransaction = (
txHash = hash txHash = hash
closeSnackbar(beforeExecutionKey) closeSnackbar(beforeExecutionKey)
pendingExecutionKey = showSnackbar(notificationsQueue.pendingExecution, enqueueSnackbar, closeSnackbar) pendingExecutionKey = showSnackbar(
notificationsQueue.pendingExecution,
enqueueSnackbar,
closeSnackbar,
)
try { try {
await saveTxToHistory( await saveTxToHistory(
@ -144,12 +195,32 @@ const createTransaction = (
console.error(err) console.error(err)
closeSnackbar(beforeExecutionKey) closeSnackbar(beforeExecutionKey)
closeSnackbar(pendingExecutionKey) closeSnackbar(pendingExecutionKey)
showSnackbar(notificationsQueue.afterExecutionError, enqueueSnackbar, closeSnackbar) showSnackbar(
notificationsQueue.afterExecutionError,
enqueueSnackbar,
closeSnackbar,
)
const executeDataUsedSignatures = safeInstance.contract.methods const executeDataUsedSignatures = safeInstance.contract.methods
.execTransaction(to, valueInWei, txData, CALL, 0, 0, 0, ZERO_ADDRESS, ZERO_ADDRESS, sigs) .execTransaction(
to,
valueInWei,
txData,
CALL,
0,
0,
0,
ZERO_ADDRESS,
ZERO_ADDRESS,
sigs,
)
.encodeABI() .encodeABI()
const errMsg = await getErrorMessage(safeInstance.address, 0, executeDataUsedSignatures, from) const errMsg = await getErrorMessage(
safeInstance.address,
0,
executeDataUsedSignatures,
from,
)
console.error(`Error creating the TX: ${errMsg}`) console.error(`Error creating the TX: ${errMsg}`)
} }

View File

@ -26,6 +26,7 @@ export const buildSafe = async (safeAddress: string, safeName: string) => {
const ethBalance = await getBalanceInEtherOf(safeAddress) const ethBalance = await getBalanceInEtherOf(safeAddress)
const threshold = Number(await gnosisSafe.getThreshold()) const threshold = Number(await gnosisSafe.getThreshold())
const nonce = Number(await gnosisSafe.nonce())
const owners = List(buildOwnersFrom(await gnosisSafe.getOwners(), await getOwners(safeAddress))) const owners = List(buildOwnersFrom(await gnosisSafe.getOwners(), await getOwners(safeAddress)))
const safe: SafeProps = { const safe: SafeProps = {
@ -34,6 +35,7 @@ export const buildSafe = async (safeAddress: string, safeName: string) => {
threshold, threshold,
owners, owners,
ethBalance, ethBalance,
nonce,
} }
return safe return safe

View File

@ -6,7 +6,6 @@ import { type GlobalState } from '~/store/index'
import { makeOwner } from '~/routes/safe/store/models/owner' import { makeOwner } from '~/routes/safe/store/models/owner'
import { makeTransaction, type Transaction } from '~/routes/safe/store/models/transaction' import { makeTransaction, type Transaction } from '~/routes/safe/store/models/transaction'
import { makeConfirmation } from '~/routes/safe/store/models/confirmation' import { makeConfirmation } from '~/routes/safe/store/models/confirmation'
import { loadSafeSubjects } from '~/utils/storage/transactions'
import { buildTxServiceUrl, type TxServiceType } from '~/logic/safe/transactions/txHistory' import { buildTxServiceUrl, type TxServiceType } from '~/logic/safe/transactions/txHistory'
import { getOwners } from '~/logic/safe/utils' import { getOwners } from '~/logic/safe/utils'
import { getWeb3 } from '~/logic/wallets/getWeb3' import { getWeb3 } from '~/logic/wallets/getWeb3'
@ -50,9 +49,7 @@ type TxServiceModel = {
export const buildTransactionFrom = async ( export const buildTransactionFrom = async (
safeAddress: string, safeAddress: string,
tx: TxServiceModel, tx: TxServiceModel,
safeSubjects: Map<string, string>,
) => { ) => {
const name = safeSubjects.get(String(tx.nonce)) || 'Unknown'
const storedOwners = await getOwners(safeAddress) const storedOwners = await getOwners(safeAddress)
const confirmations = List( const confirmations = List(
tx.confirmations.map((conf: ConfirmationServiceModel) => { tx.confirmations.map((conf: ConfirmationServiceModel) => {
@ -123,7 +120,6 @@ export const buildTransactionFrom = async (
} }
return makeTransaction({ return makeTransaction({
name,
symbol, symbol,
nonce: tx.nonce, nonce: tx.nonce,
value: tx.value.toString(), value: tx.value.toString(),
@ -189,9 +185,9 @@ export const loadSafeTransactions = async (safeAddress: string) => {
} catch (err) { } catch (err) {
console.error(`Requests for transactions for ${safeAddress} failed with 404`, err) console.error(`Requests for transactions for ${safeAddress} failed with 404`, err)
} }
const safeSubjects = loadSafeSubjects(safeAddress)
const txsRecord = await Promise.all( const txsRecord = await Promise.all(
transactions.map((tx: TxServiceModel) => buildTransactionFrom(safeAddress, tx, safeSubjects)), transactions.map((tx: TxServiceModel) => buildTransactionFrom(safeAddress, tx)),
) )
return Map().set(safeAddress, List(txsRecord)) return Map().set(safeAddress, List(txsRecord))
} }

View File

@ -1,9 +1,10 @@
// @flow // @flow
import type { Dispatch as ReduxDispatch, GetState } from 'redux' import type { Dispatch as ReduxDispatch } from 'redux'
import { List } from 'immutable' import { List } from 'immutable'
import { type Confirmation } from '~/routes/safe/store/models/confirmation' import { type Confirmation } from '~/routes/safe/store/models/confirmation'
import { type Transaction } from '~/routes/safe/store/models/transaction' import { type Transaction } from '~/routes/safe/store/models/transaction'
import { userAccountSelector } from '~/logic/wallets/store/selectors' import { userAccountSelector } from '~/logic/wallets/store/selectors'
import fetchSafe from '~/routes/safe/store/actions/fetchSafe'
import fetchTransactions from '~/routes/safe/store/actions/fetchTransactions' import fetchTransactions from '~/routes/safe/store/actions/fetchTransactions'
import { type GlobalState } from '~/store' import { type GlobalState } from '~/store'
import { getGnosisSafeInstanceAt } from '~/logic/contracts/safeContracts' import { getGnosisSafeInstanceAt } from '~/logic/contracts/safeContracts'
@ -53,7 +54,7 @@ export const generateSignaturesFromTxConfirmations = (
return sigs return sigs
} }
const processTransaction = ( type ProcessTransactionArgs = {
safeAddress: string, safeAddress: string,
tx: Transaction, tx: Transaction,
userAddress: string, userAddress: string,
@ -61,7 +62,20 @@ const processTransaction = (
enqueueSnackbar: Function, enqueueSnackbar: Function,
closeSnackbar: Function, closeSnackbar: Function,
approveAndExecute?: boolean, approveAndExecute?: boolean,
) => async (dispatch: ReduxDispatch<GlobalState>, getState: GetState<GlobalState>) => { }
const processTransaction = ({
safeAddress,
tx,
userAddress,
notifiedTransaction,
enqueueSnackbar,
closeSnackbar,
approveAndExecute,
}: ProcessTransactionArgs) => async (
dispatch: ReduxDispatch<GlobalState>,
getState: Function,
) => {
const state: GlobalState = getState() const state: GlobalState = getState()
const safeInstance = await getGnosisSafeInstanceAt(safeAddress) const safeInstance = await getGnosisSafeInstanceAt(safeAddress)
@ -69,7 +83,10 @@ const processTransaction = (
const threshold = (await safeInstance.getThreshold()).toNumber() const threshold = (await safeInstance.getThreshold()).toNumber()
const shouldExecute = threshold === tx.confirmations.size || approveAndExecute const shouldExecute = threshold === tx.confirmations.size || approveAndExecute
let sigs = generateSignaturesFromTxConfirmations(tx.confirmations, approveAndExecute && userAddress) let sigs = generateSignaturesFromTxConfirmations(
tx.confirmations,
approveAndExecute && userAddress,
)
// https://gnosis-safe.readthedocs.io/en/latest/contracts/signatures.html#pre-validated-signatures // https://gnosis-safe.readthedocs.io/en/latest/contracts/signatures.html#pre-validated-signatures
if (!sigs) { if (!sigs) {
sigs = `0x000000000000000000000000${from.replace( sigs = `0x000000000000000000000000${from.replace(
@ -78,8 +95,14 @@ const processTransaction = (
)}000000000000000000000000000000000000000000000000000000000000000001` )}000000000000000000000000000000000000000000000000000000000000000001`
} }
const notificationsQueue: NotificationsQueue = getNotificationsFromTxType(notifiedTransaction) const notificationsQueue: NotificationsQueue = getNotificationsFromTxType(
const beforeExecutionKey = showSnackbar(notificationsQueue.beforeExecution, enqueueSnackbar, closeSnackbar) notifiedTransaction,
)
const beforeExecutionKey = showSnackbar(
notificationsQueue.beforeExecution,
enqueueSnackbar,
closeSnackbar,
)
let pendingExecutionKey let pendingExecutionKey
let txHash let txHash
@ -130,7 +153,11 @@ const processTransaction = (
txHash = hash txHash = hash
closeSnackbar(beforeExecutionKey) closeSnackbar(beforeExecutionKey)
pendingExecutionKey = showSnackbar(notificationsQueue.pendingExecution, enqueueSnackbar, closeSnackbar) pendingExecutionKey = showSnackbar(
notificationsQueue.pendingExecution,
enqueueSnackbar,
closeSnackbar,
)
try { try {
await saveTxToHistory( await saveTxToHistory(
@ -169,16 +196,31 @@ const processTransaction = (
) )
dispatch(fetchTransactions(safeAddress)) dispatch(fetchTransactions(safeAddress))
if (shouldExecute) {
dispatch(fetchSafe(safeAddress))
}
return receipt.transactionHash return receipt.transactionHash
}) })
} catch (err) { } catch (err) {
console.error(err) console.error(err)
closeSnackbar(beforeExecutionKey) closeSnackbar(beforeExecutionKey)
closeSnackbar(pendingExecutionKey) closeSnackbar(pendingExecutionKey)
showSnackbar(notificationsQueue.afterExecutionError, enqueueSnackbar, closeSnackbar) showSnackbar(
notificationsQueue.afterExecutionError,
enqueueSnackbar,
closeSnackbar,
)
const executeData = safeInstance.contract.methods.approveHash(txHash).encodeABI() const executeData = safeInstance.contract.methods
const errMsg = await getErrorMessage(safeInstance.address, 0, executeData, from) .approveHash(txHash)
.encodeABI()
const errMsg = await getErrorMessage(
safeInstance.address,
0,
executeData,
from,
)
console.error(`Error executing the TX: ${errMsg}`) console.error(`Error executing the TX: ${errMsg}`)
} }

View File

@ -14,6 +14,7 @@ export type SafeProps = {
activeTokens: Set<string>, activeTokens: Set<string>,
blacklistedTokens: Set<string>, blacklistedTokens: Set<string>,
ethBalance?: string, ethBalance?: string,
nonce: number,
} }
const SafeRecord: RecordFactory<SafeProps> = Record({ const SafeRecord: RecordFactory<SafeProps> = Record({
@ -25,6 +26,7 @@ const SafeRecord: RecordFactory<SafeProps> = Record({
activeTokens: new Set(), activeTokens: new Set(),
blacklistedTokens: new Set(), blacklistedTokens: new Set(),
balances: Map({}), balances: Map({}),
nonce: 0,
}) })
export type Safe = RecordOf<SafeProps> export type Safe = RecordOf<SafeProps>

View File

@ -15,10 +15,3 @@ export const storeSubject = async (safeAddress: string, nonce: number, subject:
console.error('Error storing transaction subject in localstorage', err) console.error('Error storing transaction subject in localstorage', err)
} }
} }
export const loadSafeSubjects = (safeAddress: string): Map<string, string> => {
const key = getSubjectKeyFrom(safeAddress)
const data: any = loadFromStorage(key)
return data ? Map(data) : Map()
}

2646
yarn.lock

File diff suppressed because it is too large Load Diff