Skip to content
This repository has been archived by the owner on Nov 10, 2023. It is now read-only.

Speed up safe creation #2473

Merged
merged 12 commits into from
Jul 6, 2021
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@
"@testing-library/react": "^11.2.7",
"@typechain/web3-v1": "^3.0.0",
"@types/history": "4.6.2",
"@types/jest": "^26.0.22",
"@types/jest": "^26.0.23",
"@types/js-cookie": "^2.2.6",
"@types/lodash.get": "^4.4.6",
"@types/lodash.memoize": "^4.1.6",
Expand Down
1 change: 1 addition & 0 deletions src/logic/exceptions/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ enum ErrorCodes {
_802 = '802: Error submitting a transaction, safeAddress not found',
_803 = '803: Error creating a transaction',
_804 = '804: Error processing a transaction',
_805 = '805: TX monitor error',
_900 = '900: Error loading Safe App',
_901 = '901: Error processing Safe Apps SDK request',
_902 = '902: Error loading Safe Apps list',
Expand Down
6 changes: 6 additions & 0 deletions src/logic/notifications/notificationTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export type Notification = {
enum NOTIFICATION_IDS {
UNLOCK_WALLET_MSG,
CONNECT_WALLET_ERROR_MSG,
CREATE_SAFE_FAILED_MSG,
SIGN_TX_MSG,
TX_REJECTED_MSG,
TX_EXECUTED_MSG,
Expand Down Expand Up @@ -68,6 +69,11 @@ export const NOTIFICATIONS: Record<NotificationId, Notification> = {
message: 'Error connecting to your wallet',
options: { variant: ERROR, persist: true },
},
// Safe creation
CREATE_SAFE_FAILED_MSG: {
message: 'Safe creation failed',
options: { variant: ERROR, persist: false, autoHideDuration: longDuration },
},
// Regular/Custom Transactions
SIGN_TX_MSG: {
message: 'Please sign the transaction',
Expand Down
122 changes: 122 additions & 0 deletions src/logic/safe/transactions/__tests__/txMonitor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { txMonitor } from 'src/logic/safe/transactions/txMonitor'
import { web3ReadOnly } from 'src/logic/wallets/getWeb3'

jest.mock('src/logic/wallets/getWeb3', () => ({
web3ReadOnly: {
eth: {},
},
}))

const params = {
sender: '0x474e5Ded6b5D078163BFB8F6dBa355C3aA5478C8',
hash: '0x510bec3129a8dcc57075b67de6292ada338fa05518d18ec81b2cda3cea593a64',
nonce: 1,
data: '0',
gasPrice: '1',
}

const options = {
delay: 0,
maxRetries: 10,
}

describe('txMonitor', () => {
beforeEach(() => {
web3ReadOnly.eth.getTransaction = jest.fn(() => Promise.reject('getTransaction')) as any
web3ReadOnly.eth.getTransactionReceipt = jest.fn(() => Promise.reject('getTransactionReceipt')) as any
web3ReadOnly.eth.getBlock = jest.fn(() => Promise.reject('getBlock')) as any
})

it('should reject when max retries are reached', async () => {
try {
await txMonitor(params, options)
expect(false).toBe('Should not go here')
} catch (e) {
expect(e.message).toBe('Code 805: TX monitor error (max retries reached)')
}
expect(web3ReadOnly.eth.getTransaction).toHaveBeenCalledTimes(0)
expect(web3ReadOnly.eth.getTransactionReceipt).toHaveBeenCalledTimes(11)
})

it('should load original tx if nonce is undefined', async () => {
web3ReadOnly.eth.getTransaction = jest.fn(() => Promise.resolve({ nonce: 1, gasPrice: 1 })) as any
web3ReadOnly.eth.getTransactionReceipt = jest.fn((hash) => Promise.resolve({ hash, status: 'success' })) as any

await expect(txMonitor({ ...params, nonce: undefined }, options)).resolves.toEqual({
status: 'success',
hash: '0x510bec3129a8dcc57075b67de6292ada338fa05518d18ec81b2cda3cea593a64',
})
expect(web3ReadOnly.eth.getTransaction).toHaveBeenCalledTimes(1)
expect(web3ReadOnly.eth.getTransactionReceipt).toHaveBeenCalledTimes(1)
})

it('should fail if it cannot load the original tx receipt', async () => {
web3ReadOnly.eth.getTransaction = jest.fn(() => Promise.resolve({ nonce: 1, gasPrice: 1 })) as any
web3ReadOnly.eth.getTransactionReceipt = jest.fn(() => Promise.reject('No receipt'))

try {
await txMonitor(params, options)
expect(false).toBe('Should not go here')
} catch (e) {
expect(e.message).toBe('Code 805: TX monitor error (max retries reached)')
}

expect(web3ReadOnly.eth.getTransaction).toHaveBeenCalledTimes(0)
expect(web3ReadOnly.eth.getTransactionReceipt).toHaveBeenCalledTimes(11)
})

it('should return speed-up tx receipt', async () => {
web3ReadOnly.eth.getBlock = jest.fn(() =>
Promise.resolve({
transactions: [
{
hash: '0xSPEEDY',
from: params.sender,
nonce: params.nonce,
input: params.data,
},
],
}),
) as any

web3ReadOnly.eth.getTransactionReceipt = jest.fn((hash) => {
return hash === '0xSPEEDY'
? Promise.resolve({ hash, status: 'success' } as any)
: Promise.reject('No original receipt')
})

await expect(txMonitor(params, options)).resolves.toEqual({
status: 'success',
hash: '0xSPEEDY',
})
expect(web3ReadOnly.eth.getBlock).toHaveBeenCalledTimes(1)
expect(web3ReadOnly.eth.getTransactionReceipt).toHaveBeenCalledTimes(2)
})

it('should fail if it cannot find a speed-up tx', async () => {
web3ReadOnly.eth.getBlock = jest.fn(() =>
Promise.resolve({
transactions: [
{
hash: '0x123',
from: 'Someone',
nonce: 12,
input: '123',
},
],
}),
) as any

web3ReadOnly.eth.getTransactionReceipt = jest.fn(() => Promise.reject('No original receipt'))

try {
await txMonitor(params, options)
expect(false).toBe('Should not go here')
} catch (e) {
expect(e.message).toBe('Code 805: TX monitor error (max retries reached)')
}

expect(web3ReadOnly.eth.getBlock).toHaveBeenCalledTimes(11)
expect(web3ReadOnly.eth.getTransactionReceipt).toHaveBeenCalledTimes(11)
})
})
134 changes: 134 additions & 0 deletions src/logic/safe/transactions/txMonitor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { Transaction, TransactionReceipt } from 'web3-core'

import { web3ReadOnly } from 'src/logic/wallets/getWeb3'
import { sameAddress } from 'src/logic/wallets/ethAddresses'
import { sameString } from 'src/utils/strings'
import { CodedException, Errors } from 'src/logic/exceptions/CodedException'

type TxMonitorProps = {
sender: string
hash: string
data: string
nonce?: number
gasPrice?: string
}

type TxMonitorOptions = {
delay?: number
maxRetries?: number
}

const MAX_RETRIES = 720
const DEFAULT_DELAY = 5000

async function findSpeedupTx({ sender, hash, nonce, data }: TxMonitorProps): Promise<Transaction | undefined> {
const latestBlock = await web3ReadOnly.eth.getBlock('latest', true)

const replacementTransaction = latestBlock.transactions.find((transaction) => {
// TODO: use gasPrice, timestamp or another better way to differentiate
return (
sameAddress(transaction.from, sender) &&
transaction.nonce === nonce &&
!sameString(transaction.hash, hash) &&
// if `data` differs, then it's a replacement tx, not a speedup
sameString(transaction.input, data)
)
})

return replacementTransaction
}

/**
* Recursively inspects a pending tx. Until it's found, and returns the mined tx receipt
*
* @param {object} txParams
* @param {string} txParams.sender
* @param {string} txParams.hash
* @param {string} txParams.data
* @param {number | undefined} txParams.nonce
* @param {string | undefined} txParams.gasPrice
* @param {object} options
* @param {number} options.delay
* @returns {Promise<TransactionReceipt>}
*/
export const txMonitor = (
{ sender, hash, data, nonce, gasPrice }: TxMonitorProps,
options?: TxMonitorOptions,
tries = 0,
): Promise<TransactionReceipt> => {
return new Promise<TransactionReceipt>((resolve, reject) => {
const { maxRetries = MAX_RETRIES } = options || {}
if (tries > maxRetries) {
reject(new CodedException(Errors._805, 'max retries reached'))
return
}

const monitorFn = async (): Promise<unknown> => {
// Case 1: this block is accessed for the first time, no nonce
if (nonce == null || gasPrice == null) {
let params: TxMonitorProps = { sender, hash, data }
try {
// Find the nonce for the current tx
const transaction = await web3ReadOnly.eth.getTransaction(hash)
if (transaction) {
params = { ...params, nonce: transaction.nonce, gasPrice: transaction.gasPrice }
}
} catch (e) {
// ignore error
}

return txMonitor(params, options, tries + 1)
.then(resolve)
.catch(reject)
}

// Case 2: the nonce exists, try to get the receipt for the original tx
try {
const firstTxReceipt = await web3ReadOnly.eth.getTransactionReceipt(hash)
if (firstTxReceipt) {
return resolve(firstTxReceipt)
}
} catch (e) {
// proceed to case 3
}

// Case 3: original tx not found, try to find a sped-up tx
try {
const replacementTx = await findSpeedupTx({ sender, hash, nonce, data })

if (replacementTx) {
const replacementReceipt = await web3ReadOnly.eth.getTransactionReceipt(replacementTx.hash)

// goal achieved
if (replacementReceipt) {
return resolve(replacementReceipt)
}

// tx exists but no receipt yet, it's pending
return txMonitor(
{
sender,
nonce,
hash: replacementTx.hash,
data: replacementTx.input,
gasPrice: replacementTx.gasPrice,
},
options,
tries + 1,
)
.then(resolve)
.catch(reject)
}
} catch (e) {
// ignore error
}

// Neither the original nor a replacement transactions were found, try again
txMonitor({ sender, hash, data, nonce, gasPrice }, options, tries + 1)
.then(resolve)
.catch(reject)
}

setTimeout(monitorFn, options?.delay ?? DEFAULT_DELAY)
})
}
56 changes: 37 additions & 19 deletions src/routes/open/container/Open.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Loader } from '@gnosis.pm/safe-react-components'
import { backOff } from 'exponential-backoff'
import queryString from 'query-string'
import React, { useEffect, useState, ReactElement } from 'react'
import React, { ReactElement, useEffect, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { useLocation } from 'react-router-dom'
import { TransactionReceipt } from 'web3-core'
Expand Down Expand Up @@ -29,6 +29,7 @@ import { userAccountSelector } from 'src/logic/wallets/store/selectors'
import { addOrUpdateSafe } from 'src/logic/safe/store/actions/addOrUpdateSafe'
import { useAnalytics } from 'src/utils/googleAnalytics'
import { sleep } from 'src/utils/timer'
import { txMonitor } from 'src/logic/safe/transactions/txMonitor'

const SAFE_PENDING_CREATION_STORAGE_KEY = 'SAFE_PENDING_CREATION_STORAGE_KEY'

Expand Down Expand Up @@ -78,21 +79,38 @@ const getSafePropsValuesFromQueryParams = (queryParams: SafeCreationQueryParams)
}

export const createSafe = async (values: CreateSafeValues, userAccount: string): Promise<TransactionReceipt> => {
const confirmations = getThresholdFrom(values)
const ownerAddresses = getAccountsFrom(values)
const safeCreationSalt = getSafeCreationSaltFrom(values)
const deploymentTx = getSafeDeploymentTransaction(ownerAddresses, confirmations, safeCreationSalt)

const receipt = await deploymentTx
.send({
from: userAccount,
gas: values?.gasLimit,
})
.once('transactionHash', (txHash) => {
saveToStorage(SAFE_PENDING_CREATION_STORAGE_KEY, { txHash, ...values })
})

return receipt
return new Promise((resolve, reject) => {
const confirmations = getThresholdFrom(values)
const ownerAddresses = getAccountsFrom(values)
const safeCreationSalt = getSafeCreationSaltFrom(values)
const deploymentTx = getSafeDeploymentTransaction(ownerAddresses, confirmations, safeCreationSalt)

deploymentTx
.send({
from: userAccount,
gas: values?.gasLimit,
})
.once('transactionHash', (txHash) => {
saveToStorage(SAFE_PENDING_CREATION_STORAGE_KEY, { txHash, ...values })

// Monitor the latest block to find a potential speed-up tx
txMonitor({ sender: userAccount, hash: txHash, data: deploymentTx.encodeABI() })
.then((txReceipt) => {
console.log('Speed up tx mined:', txReceipt)
resolve(txReceipt)
})
.catch((error) => {
reject(error)
})
})
.then((txReceipt) => {
console.log('First tx mined:', txReceipt)
resolve(txReceipt)
})
.catch((error) => {
reject(error)
})
})
}

const Open = (): ReactElement => {
Expand Down Expand Up @@ -170,9 +188,6 @@ const Open = (): ReactElement => {
const safe = makeAddressBookEntry({ address: safeAddress, name })
await dispatch(addressBookSafeLoad([...owners, safe]))

const safeProps = await buildSafe(safeAddress)
await dispatch(addOrUpdateSafe(safeProps))

trackEvent({
category: 'User',
action: 'Created a safe',
Expand All @@ -189,6 +204,9 @@ const Open = (): ReactElement => {
},
})

const safeProps = await buildSafe(safeAddress)
await dispatch(addOrUpdateSafe(safeProps))

await removeFromStorage(SAFE_PENDING_CREATION_STORAGE_KEY)
const url = {
pathname: `${SAFELIST_ADDRESS}/${safeProps.address}/balances`,
Expand Down
2 changes: 2 additions & 0 deletions src/routes/opening/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,8 @@ export const SafeDeployment = ({
(err: Error) => {
if (isTxPendingError(err)) {
dispatch(enqueueSnackbar({ ...NOTIFICATIONS.TX_PENDING_MSG }))
} else {
dispatch(enqueueSnackbar({ ...NOTIFICATIONS.CREATE_SAFE_FAILED_MSG }))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it wasn't being done before because the interface itself shows an error.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't see the notification

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There was no notification, I meant that the page itself shows an error graphic and suggests to retry.
Notification doesn't hurt though.

}
},
[dispatch],
Expand Down
Loading