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

fix: history polling next/transactions remaining pending #3992

Merged
merged 7 commits into from
Jun 22, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import * as gatewaySDK from '@gnosis.pm/safe-react-gateway-sdk'
import { FilterType } from 'src/routes/safe/components/Transactions/TxList/Filter'
import { _getHistoryPageUrl, _getTxHistory } from '../fetchTransactions/loadGatewayTransactions'

jest.mock('@gnosis.pm/safe-react-gateway-sdk', () => {
const original = jest.requireActual('@gnosis.pm/safe-react-gateway-sdk')
return {
...original,
getIncomingTransfers: jest.fn,
getMultisigTransactions: jest.fn,
getModuleTransactions: jest.fn,
getTransactionHistory: jest.fn,
}
})

describe('loadGatewayTransactions', () => {
beforeEach(() => {
jest.resetAllMocks()
})

describe('getTxHistory', () => {
it('fetches incoming transfers according to type', async () => {
const spy = jest.spyOn(gatewaySDK, 'getIncomingTransfers')

await _getTxHistory('4', '0x123', { type: FilterType.INCOMING, token_address: '0x456' })

expect(spy).toHaveBeenCalledWith('4', '0x123', { token_address: '0x456' }, undefined)
})

it('fetches multisig transfers according to type', async () => {
const spy = jest.spyOn(gatewaySDK, 'getMultisigTransactions')

await _getTxHistory('4', '0x123', { type: FilterType.MULTISIG, to: '0x456' })

expect(spy).toHaveBeenCalledWith('4', '0x123', { to: '0x456', executed: 'true' }, undefined)
})

it('fetches module transfers according to type', async () => {
const spy = jest.spyOn(gatewaySDK, 'getModuleTransactions')

await _getTxHistory('4', '0x123', { type: FilterType.MODULE, to: '0x456' })

expect(spy).toHaveBeenCalledWith('4', '0x123', { to: '0x456' }, undefined)
})

it('fetches historical transfers by default', async () => {
const spy = jest.spyOn(gatewaySDK, 'getTransactionHistory')

await _getTxHistory('4', '0x123')

expect(spy).toHaveBeenCalledWith('4', '0x123', undefined)
})
})

describe('getHistoryPageUrl', () => {
it('returns the pageUrl when a falsy pageUrl is provided', () => {
// SDK types should allow for `null` in TransactionListPage['next' | 'previous'] as it's returned by gateway
expect(_getHistoryPageUrl(null as unknown as undefined, { type: FilterType.INCOMING })).toBe(null)
})

it('returns the pageUrl when a no filter is provided', () => {
expect(_getHistoryPageUrl('http://test123.com', undefined)).toBe('http://test123.com')
expect(_getHistoryPageUrl('http://test456.com', {})).toBe('http://test456.com')
})

it('returns the pageUrl if it is an invalid URL', () => {
expect(_getHistoryPageUrl('test123', { type: FilterType.INCOMING })).toBe('test123')
})

it('appends only defined filter values to the pageUrl', () => {
expect(_getHistoryPageUrl('http://test123.com', { type: FilterType.INCOMING, value: undefined })).toBe(
'http://test123.com/?type=Incoming',
)
expect(
_getHistoryPageUrl('http://test456.com', { type: FilterType.MULTISIG, execution_date__gte: undefined }),
).toBe('http://test456.com/?type=Outgoing')
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -16,84 +16,76 @@ import {
getMultisigFilter,
getModuleFilter,
} from 'src/routes/safe/components/Transactions/TxList/Filter/utils'
import { ChainId } from 'src/config/chain.d'
import { operations } from '@gnosis.pm/safe-react-gateway-sdk/dist/types/api'

/*************/
/* HISTORY */
/*************/
const historyPointers: {
[chainId: string]: {
[safeAddress: string]: {
next?: string
previous?: string
}
}
} = {}

const getHistoryTxListPage = async (
chainId: ChainId,
export const _getTxHistory = async (
chainId: string,
safeAddress: string,
filter?: FilterForm | Partial<FilterForm>,
next?: string,
): Promise<TransactionListPage> => {
let txListPage: TransactionListPage = {
next: undefined,
previous: undefined,
results: [],
}

const { next } = historyPointers[chainId]?.[safeAddress] || {}

let query:
| operations['incoming_transfers' | 'incoming_transfers' | 'module_transactions']['parameters']['query']
| undefined

switch (filter?.[FILTER_TYPE_FIELD_NAME]) {
case FilterType.INCOMING: {
query = filter ? getIncomingFilter(filter) : undefined
txListPage = await getIncomingTransfers(chainId, safeAddress, query, next)
txListPage = await getIncomingTransfers(chainId, safeAddress, getIncomingFilter(filter), next)
break
}
case FilterType.MULTISIG: {
query = filter ? getMultisigFilter(filter, true) : undefined
txListPage = await getMultisigTransactions(chainId, safeAddress, query, next)
txListPage = await getMultisigTransactions(chainId, safeAddress, getMultisigFilter(filter, true), next)
break
}
case FilterType.MODULE: {
query = filter ? getModuleFilter(filter) : undefined
txListPage = await getModuleTransactions(chainId, safeAddress, query, next)
txListPage = await getModuleTransactions(chainId, safeAddress, getModuleFilter(filter), next)
break
}
default: {
txListPage = await getTransactionHistory(chainId, safeAddress, next)
}
}

const getPageUrl = (pageUrl?: string): string | undefined => {
if (!pageUrl || !query) {
return pageUrl
}
return txListPage
}

let url: URL
export const _getHistoryPageUrl = (pageUrl?: string, filter?: FilterForm | Partial<FilterForm>): undefined | string => {
if (!pageUrl || !filter || Object.keys(filter).length === 0) {
return pageUrl
}

try {
url = new URL(pageUrl)
} catch {
return pageUrl
}
let url: URL

Object.entries(query).forEach(([key, value]) => {
try {
url = new URL(pageUrl)
} catch {
return pageUrl
}

Object.entries(filter)
.filter(([, value]) => value !== undefined)
.forEach(([key, value]) => {
url.searchParams.set(key, String(value))
})

return url.toString()
}
return url.toString()
}

historyPointers[chainId][safeAddress].next = getPageUrl(txListPage?.next)
historyPointers[chainId][safeAddress].previous = getPageUrl(txListPage?.previous)
const historyPointers: { [chainId: string]: { [safeAddress: string]: { next?: string; previous?: string } } } = {}

return txListPage
}
const getHistoryPointer = (
next?: string,
previous?: string,
filter?: FilterForm | Partial<FilterForm>,
): { next?: string; previous?: string } => ({
next: _getHistoryPageUrl(next, filter),
previous: _getHistoryPageUrl(previous, filter),
})

/**
* Fetch next page if there is a next pointer for the safeAddress.
Expand All @@ -102,17 +94,26 @@ const getHistoryTxListPage = async (
*/
export const loadPagedHistoryTransactions = async (
safeAddress: string,
filter?: FilterForm | Partial<FilterForm>,
): Promise<{ values: HistoryGatewayResponse['results']; next?: string } | undefined> => {
const chainId = _getChainId()

if (!historyPointers[chainId]?.[safeAddress]?.next) {
// if `historyPointers[safeAddress] is `undefined` it means `loadHistoryTransactions` wasn't called
// if `historyPointers[safeAddress].next is `null`, it means it reached the last page in gateway-client
if (!historyPointers[chainId][safeAddress]?.next) {
throw new CodedException(Errors._608)
}

try {
const { results, next } = await getHistoryTxListPage(chainId, safeAddress)
const { results, next, previous } = await _getTxHistory(
chainId,
checksumAddress(safeAddress),
filter,
historyPointers[chainId][safeAddress].next,
)

historyPointers[chainId][safeAddress] = getHistoryPointer(next, previous, filter)

return { values: results, next }
return { values: results, next: historyPointers[chainId][safeAddress].next }
} catch (e) {
throw new CodedException(Errors._602, e.message)
}
Expand All @@ -123,20 +124,14 @@ export const loadHistoryTransactions = async (
filter?: FilterForm | Partial<FilterForm>,
): Promise<HistoryGatewayResponse['results']> => {
const chainId = _getChainId()
try {
const { results, next, previous } = await _getTxHistory(chainId, checksumAddress(safeAddress), filter)

if (!historyPointers[chainId]) {
historyPointers[chainId] = {}
}

if (!historyPointers[chainId][safeAddress] || filter) {
historyPointers[chainId][safeAddress] = {
next: undefined,
previous: undefined,
if (!historyPointers[chainId]) {
historyPointers[chainId] = {}
}
}

try {
const { results } = await getHistoryTxListPage(chainId, safeAddress, filter)
historyPointers[chainId][safeAddress] = getHistoryPointer(next, previous, filter)

return results
} catch (e) {
Expand Down
39 changes: 20 additions & 19 deletions src/routes/safe/components/Transactions/TxList/Filter/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ReactElement, useCallback, useEffect, useState } from 'react'
import { ReactElement, useEffect, useState } from 'react'
import { Controller, DefaultValues, useForm } from 'react-hook-form'
import styled from 'styled-components'
import ExpandMoreIcon from '@material-ui/icons/ExpandMore'
Expand Down Expand Up @@ -144,28 +144,29 @@ const Filter = (): ReactElement => {
})
}

const clearFilter = useCallback(
({ clearSearch = true } = {}) => {
if (search && clearSearch) {
history.replace(pathname)
dispatch(loadTransactions({ chainId, safeAddress: checksumAddress(safeAddress) }))
reset(defaultValues)
}
const clearFilter = () => {
history.replace({ search: '' })
dispatch(loadTransactions({ chainId, safeAddress: checksumAddress(safeAddress) }))
reset(defaultValues)
hideFilter()
}

hideFilter()
},
[search, history, pathname, chainId, dispatch, reset, safeAddress],
)
const filterType = watch(FILTER_TYPE_FIELD_NAME)

useEffect(() => {
// We cannot rely on cleanup as setting search params (in onSubmit) unmounts the component
const unsubscribe = history.listen((newLocation) => {
const shouldResetHistory = filterType && newLocation.pathname !== pathname
if (shouldResetHistory) {
dispatch(loadTransactions({ chainId, safeAddress: checksumAddress(safeAddress) }))
}
})

return () => {
// If search is programatically cleared on unmount, the router routes back to here
// Search is inherently cleared when unmounting either way
clearFilter({ clearSearch: false })
unsubscribe()
}
}, [clearFilter])

const filterType = watch(FILTER_TYPE_FIELD_NAME)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])

const onSubmit = (filter: FilterForm) => {
// Don't apply the same filter twice
Expand All @@ -175,7 +176,7 @@ const Filter = (): ReactElement => {

const query = Object.fromEntries(Object.entries(filter).filter(([, value]) => !!value))

history.replace({ pathname, search: `?${new URLSearchParams(query).toString()}` })
history.replace({ search: `?${new URLSearchParams(query).toString()}` })

dispatch(loadTransactions({ chainId, safeAddress: checksumAddress(safeAddress), filter }))

Expand Down