Commit 6d5e17a6 authored by Jordan Frankfurt's avatar Jordan Frankfurt Committed by GitHub

fix: deduplicate remote and local tx lists (#6438)

* fix: deduplicate remote and local tx lists

* add nonce to local transactions

* removes local transactions that have nonces that are duplicates of remote transactions

* add e2e test

* lint fix

* fix types in test

* use supported chain id for reducer tests

* use getReceipt to remove outdated transactions via existing polling setup

* pr nits from cmcewen

* fix lint

* fix test
parent 8301c589
import { getTestSelector } from '../../utils'
describe('mini-portfolio activity history', () => {
afterEach(() => {
cy.intercept(
{
method: 'POST',
url: 'https://beta.api.uniswap.org/v1/graphql',
},
// Pass an empty object to allow the original behavior
{}
).as('restoreOriginalBehavior')
})
it('should deduplicate activity history by nonce', () => {
cy.visit('/swap', { ethereum: 'hardhat' })
.hardhat({ automine: false })
.then((hardhat) => hardhat.wallet.getTransactionCount())
.then((currentNonce) => {
const nextNonce = currentNonce + 1
// Mock graphql response to include a specific nonce.
cy.intercept(
{
method: 'POST',
url: 'https://beta.api.uniswap.org/v1/graphql',
},
{
body: {
data: {
portfolios: [
{
id: 'UG9ydGZvbGlvOjB4NUNlYUI3NGU0NDZkQmQzYkY2OUUyNzcyMDBGMTI5ZDJiQzdBMzdhMQ==',
assetActivities: [
{
id: 'QXNzZXRBY3Rpdml0eTpWSEpoYm5OaFkzUnBiMjQ2TUhnME5tUm1PVGs0T0RrNVl6UmtNR1kzWTJNNE9HRTVNVFEzTURBME9EWmtOVGhrTURnNFpqbG1NelkxTnpRM1l6WXdZek15WVRFNE4yWXlaRFEwWVdVNFh6QjRZV1EyWXpCa05XTmlOVEZsWWpjMU5qUTFaRGszT1RneE4yRTJZVEkxTmpreU1UbG1ZbVE1Wmw4d2VEQXpOR0UwTURjMk5EUTROV1kzWlRBNFkyRXhOak0yTm1VMU1ETTBPVEZoTm1GbU56ZzFNR1E9',
timestamp: 1681150079,
type: 'UNKNOWN',
chain: 'ETHEREUM',
transaction: {
id: 'VHJhbnNhY3Rpb246MHg0NmRmOTk4ODk5YzRkMGY3Y2M4OGE5MTQ3MDA0ODZkNThkMDg4ZjlmMzY1NzQ3YzYwYzMyYTE4N2YyZDQ0YWU4XzB4YWQ2YzBkNWNiNTFlYjc1NjQ1ZDk3OTgxN2E2YTI1NjkyMTlmYmQ5Zl8weDAzNGE0MDc2NDQ4NWY3ZTA4Y2ExNjM2NmU1MDM0OTFhNmFmNzg1MGQ=',
blockNumber: 17019453,
hash: '0x46df998899c4d0f7cc88a914700486d58d088f9f365747c60c32a187f2d44ae8',
status: 'CONFIRMED',
to: '0x034a40764485f7e08ca16366e503491a6af7850d',
from: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266',
nonce: currentNonce,
__typename: 'Transaction',
},
assetChanges: [],
__typename: 'AssetActivity',
},
{
id: 'QXNzZXRBY3Rpdml0eTpWSEpoYm5OaFkzUnBiMjQ2TUhneE16UXpaR1ppTlROaE9XRmpNR00yWW1aaVpqUTNNRFEyWWpObFkyRXhORGN3TUdZd00yWXhOMkV3WWpnM1pqWXpPRFpsWVRnNU16QTRNVFZtWmpoaFh6QjRZMkUzTXpOalkySm1OelZoTXpnME1ERXhPR1ZpT1RjNU9EVTJOemRpTkdRMk56TTBZemMwWmw4d2VERmlOVEUxTkdGaE5HSTRaakF5TjJJNVptUXhPVE0wTVRFek1tWmpPV1JoWlRFd1pqY3pOVGs9',
timestamp: 1681149995,
type: 'SEND',
chain: 'ETHEREUM',
transaction: {
id: 'VHJhbnNhY3Rpb246MHgxMzQzZGZiNTNhOWFjMGM2YmZiZjQ3MDQ2YjNlY2ExNDcwMGYwM2YxN2EwYjg3ZjYzODZlYTg5MzA4MTVmZjhhXzB4Y2E3MzNjY2JmNzVhMzg0MDExOGViOTc5ODU2NzdiNGQ2NzM0Yzc0Zl8weDFiNTE1NGFhNGI4ZjAyN2I5ZmQxOTM0MTEzMmZjOWRhZTEwZjczNTk=',
blockNumber: 17019446,
hash: '0x1343dfb53a9ac0c6bfbf47046b3eca14700f03f17a0b87f6386ea8930815ff8a',
status: 'CONFIRMED',
to: '0x1b5154aa4b8f027b9fd19341132fc9dae10f7359',
from: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266',
nonce: nextNonce,
__typename: 'Transaction',
},
assetChanges: [
{
__typename: 'TokenTransfer',
id: 'VG9rZW5UcmFuc2ZlcjoweDVjZWFiNzRlNDQ2ZGJkM2JmNjllMjc3MjAwZjEyOWQyYmM3YTM3YTFfMHhiMWRjNDlmMDY1N2FkNTA1YjUzNzUyN2RkOWE1MDk0YTM2NTkzMWMxXzB4MTM0M2RmYjUzYTlhYzBjNmJmYmY0NzA0NmIzZWNhMTQ3MDBmMDNmMTdhMGI4N2Y2Mzg2ZWE4OTMwODE1ZmY4YQ==',
asset: {
id: 'VG9rZW46RVRIRVJFVU1fMHgxY2MyYjA3MGNhZjAxNmE3ZGRjMzA0N2Y5MzI3MmU4Yzc3YzlkZGU5',
name: 'USD Coin (USDC)',
symbol: 'USDC',
address: '0x1cc2b070caf016a7ddc3047f93272e8c77c9dde9',
decimals: 6,
chain: 'ETHEREUM',
standard: null,
project: {
id: 'VG9rZW5Qcm9qZWN0OkVUSEVSRVVNXzB4MWNjMmIwNzBjYWYwMTZhN2RkYzMwNDdmOTMyNzJlOGM3N2M5ZGRlOQ==',
isSpam: true,
logo: null,
__typename: 'TokenProject',
},
__typename: 'Token',
},
tokenStandard: 'ERC20',
quantity: '18011.212084',
sender: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266',
recipient: '0xb1dc49f0657ad505b537527dd9a5094a365931c1',
direction: 'OUT',
transactedValue: null,
},
],
__typename: 'AssetActivity',
},
],
__typename: 'Portfolio',
},
],
},
},
}
).as('graphqlMock')
// Input swap info.
cy.get('#swap-currency-input .token-amount-input').clear().type('1')
cy.get('#swap-currency-output .open-currency-select-button').click()
cy.contains('USDC').click()
cy.get('#swap-currency-output .token-amount-input').should('not.equal', '')
// Set slippage to a high value.
cy.get(getTestSelector('open-settings-dialog-button')).click()
cy.get(getTestSelector('max-slippage-settings')).click()
cy.get(getTestSelector('slippage-input')).clear().type('5')
cy.get('body').click('topRight')
cy.get(getTestSelector('slippage-input')).should('not.exist')
// Click swap button.
cy.contains('1 USDC = ').should('exist')
cy.get('#swap-button').should('not.be', 'disabled').click()
cy.get('#confirm-swap-or-send').click()
cy.get(getTestSelector('dismiss-tx-confirmation')).click()
// Check activity history tab.
cy.get(getTestSelector('web3-status-connected')).click()
cy.get(getTestSelector('mini-portfolio-nav-activity')).click()
// Assert that the local pending transaction is replaced by a remote transaction with the same nonce.
cy.contains('Swapping').should('not.exist')
})
})
})
......@@ -87,10 +87,20 @@ function combineActivities(localMap: ActivityMap = {}, remoteMap: ActivityMap =
// Merges local and remote activities w/ same hash, preferring remote data
return txHashes.reduce((acc: Array<Activity>, hash) => {
const localActivity = localMap?.[hash] ?? {}
const remoteActivity = remoteMap?.[hash] ?? {}
// TODO(cartcrom): determine best logic for which fields to prefer from which sources, i.e. prefer remote exact swap output instead of local estimated output
acc.push({ ...remoteActivity, ...localActivity } as Activity)
const localActivity = (localMap?.[hash] ?? {}) as Activity
const remoteActivity = (remoteMap?.[hash] ?? {}) as Activity
// Check for nonce collision
const isNonceCollision =
localActivity.nonce !== undefined &&
Object.keys(remoteMap).some((remoteHash) => remoteMap[remoteHash]?.nonce === localActivity.nonce)
if (!isNonceCollision) {
// TODO(cartcrom): determine best logic for which fields to prefer from which sources
// i.e.prefer remote exact swap output instead of local estimated output
acc.push({ ...localActivity, ...remoteActivity } as Activity)
}
return acc
}, [])
}
......
......@@ -3,7 +3,7 @@ import { formatCurrencyAmount } from '@uniswap/conedison/format'
import { Currency, CurrencyAmount, TradeType } from '@uniswap/sdk-core'
import { nativeOnChain } from '@uniswap/smart-order-router'
import { SupportedChainId } from 'constants/chains'
import { TransactionPartsFragment, TransactionStatus } from 'graphql/data/__generated__/types-and-hooks'
import { TransactionStatus } from 'graphql/data/__generated__/types-and-hooks'
import { ChainTokenMap, useAllTokensMultichain } from 'hooks/Tokens'
import { useMemo } from 'react'
import { useMultichainTransactions } from 'state/transactions/hooks'
......@@ -141,7 +141,7 @@ export function parseLocalActivity(
? TransactionStatus.Confirmed
: TransactionStatus.Failed
const receipt: TransactionPartsFragment | undefined = details.receipt
const receipt = details.receipt
? {
id: details.receipt.transactionHash,
...details.receipt,
......@@ -157,6 +157,7 @@ export function parseLocalActivity(
status,
timestamp: (details.confirmedTime ?? details.addedTime) / 1000,
receipt,
nonce: details.nonce,
}
let additionalFields: Partial<Activity> = {}
......
......@@ -254,6 +254,7 @@ function parseRemoteActivity(assetActivity: AssetActivityPartsFragment): Activit
title: assetActivity.type,
descriptor: assetActivity.transaction.to,
receipt: assetActivity.transaction,
nonce: assetActivity.transaction.nonce,
}
const parsedFields = ActivityParserByType[assetActivity.type]?.(changes, assetActivity)
......
......@@ -14,7 +14,8 @@ export type Activity = {
logos?: Array<string | undefined>
currencies?: Array<Currency | undefined>
otherAccount?: string
receipt?: Receipt
receipt?: Omit<Receipt, 'nonce'>
nonce?: number | null
}
export type ActivityMap = { [hash: string]: Activity | undefined }
......@@ -99,6 +99,7 @@ fragment TransactionParts on Transaction {
status
to
from
nonce
}
fragment AssetActivityParts on AssetActivity {
......
......@@ -4,6 +4,8 @@ import { SupportedChainId } from 'constants/chains'
import useBlockNumber, { useFastForwardBlockNumber } from 'lib/hooks/useBlockNumber'
import ms from 'ms.macro'
import { useCallback, useEffect } from 'react'
import { useTransactionRemover } from 'state/transactions/hooks'
import { TransactionDetails } from 'state/transactions/types'
import { retry, RetryableError, RetryOptions } from 'utils/retry'
interface Transaction {
......@@ -39,16 +41,17 @@ const RETRY_OPTIONS_BY_CHAIN_ID: { [chainId: number]: RetryOptions } = {
const DEFAULT_RETRY_OPTIONS: RetryOptions = { n: 1, minWait: 0, maxWait: 0 }
interface UpdaterProps {
pendingTransactions: { [hash: string]: Transaction }
pendingTransactions: { [hash: string]: TransactionDetails }
onCheck: (tx: { chainId: number; hash: string; blockNumber: number }) => void
onReceipt: (tx: { chainId: number; hash: string; receipt: TransactionReceipt }) => void
}
export default function Updater({ pendingTransactions, onCheck, onReceipt }: UpdaterProps): null {
const { chainId, provider } = useWeb3React()
const { account, chainId, provider } = useWeb3React()
const lastBlockNumber = useBlockNumber()
const fastForwardBlockNumber = useFastForwardBlockNumber()
const removeTransaction = useTransactionRemover()
const getReceipt = useCallback(
(hash: string) => {
......@@ -56,8 +59,18 @@ export default function Updater({ pendingTransactions, onCheck, onReceipt }: Upd
const retryOptions = RETRY_OPTIONS_BY_CHAIN_ID[chainId] ?? DEFAULT_RETRY_OPTIONS
return retry(
() =>
provider.getTransactionReceipt(hash).then((receipt) => {
provider.getTransactionReceipt(hash).then(async (receipt) => {
if (receipt === null) {
if (account) {
const transactionCount = await provider.getTransactionCount(account)
const tx = pendingTransactions[hash]
// We check for the presence of a nonce because we haven't always saved them,
// so this code may run against old store state where nonce is undefined.
if (tx.nonce && tx.nonce < transactionCount) {
// We remove pending transactions from redux if they are no longer the latest nonce.
removeTransaction(hash)
}
}
console.debug(`Retrying tranasaction receipt for ${hash}`)
throw new RetryableError()
}
......@@ -66,7 +79,7 @@ export default function Updater({ pendingTransactions, onCheck, onReceipt }: Upd
retryOptions
)
},
[chainId, provider]
[account, chainId, pendingTransactions, provider, removeTransaction]
)
useEffect(() => {
......
......@@ -5,7 +5,7 @@ import { ALL_SUPPORTED_CHAIN_IDS, SupportedChainId } from 'constants/chains'
import { useCallback, useMemo } from 'react'
import { useAppDispatch, useAppSelector } from 'state/hooks'
import { addTransaction } from './reducer'
import { addTransaction, removeTransaction } from './reducer'
import { TransactionDetails, TransactionInfo, TransactionType } from './types'
// helper that can take a ethers library transaction response and add it to the list of transactions
......@@ -18,11 +18,26 @@ export function useTransactionAdder(): (response: TransactionResponse, info: Tra
if (!account) return
if (!chainId) return
const { hash } = response
const { hash, nonce } = response
if (!hash) {
throw Error('No transaction hash found.')
}
dispatch(addTransaction({ hash, from: account, info, chainId }))
dispatch(addTransaction({ hash, from: account, info, chainId, nonce }))
},
[account, chainId, dispatch]
)
}
export function useTransactionRemover() {
const { chainId, account } = useWeb3React()
const dispatch = useAppDispatch()
return useCallback(
(hash: string) => {
if (!account) return
if (!chainId) return
dispatch(removeTransaction({ hash, chainId }))
},
[account, chainId, dispatch]
)
......
import { SupportedChainId } from 'constants/chains'
import { createStore, Store } from 'redux'
import { updateVersion } from '../global/actions'
......@@ -28,7 +29,7 @@ describe('transaction reducer', () => {
},
})
store.dispatch(updateVersion())
expect(store.getState()[1]['abc']).toBeUndefined()
expect(store.getState()[SupportedChainId.MAINNET]['abc']).toBeUndefined()
})
it('keeps old format transactions that do have info', () => {
store = createStore(reducer, {
......@@ -40,7 +41,7 @@ describe('transaction reducer', () => {
},
})
store.dispatch(updateVersion())
expect(store.getState()[1]['abc']).toBeTruthy()
expect(store.getState()[SupportedChainId.MAINNET]['abc']).toBeTruthy()
})
})
......@@ -52,6 +53,7 @@ describe('transaction reducer', () => {
chainId: 1,
hash: '0x0',
from: 'abc',
nonce: 1,
info: {
type: TransactionType.APPROVAL,
tokenAddress: 'abc',
......@@ -79,7 +81,7 @@ describe('transaction reducer', () => {
it('no op if not valid transaction', () => {
store.dispatch(
finalizeTransaction({
chainId: 4,
chainId: SupportedChainId.MAINNET,
hash: '0x0',
receipt: {
status: 1,
......@@ -99,7 +101,8 @@ describe('transaction reducer', () => {
store.dispatch(
addTransaction({
hash: '0x0',
chainId: 4,
chainId: SupportedChainId.MAINNET,
nonce: 2,
info: { type: TransactionType.APPROVAL, spender: '0x0', tokenAddress: '0x0' },
from: '0x0',
})
......@@ -107,7 +110,7 @@ describe('transaction reducer', () => {
const beforeTime = new Date().getTime()
store.dispatch(
finalizeTransaction({
chainId: 4,
chainId: SupportedChainId.MAINNET,
hash: '0x0',
receipt: {
status: 1,
......@@ -121,7 +124,7 @@ describe('transaction reducer', () => {
},
})
)
const tx = store.getState()[4]?.['0x0']
const tx = store.getState()[SupportedChainId.MAINNET]?.['0x0']
expect(tx?.confirmedTime).toBeGreaterThanOrEqual(beforeTime)
expect(tx?.receipt).toEqual({
status: 1,
......@@ -140,7 +143,7 @@ describe('transaction reducer', () => {
it('no op if not valid transaction', () => {
store.dispatch(
checkedTransaction({
chainId: 4,
chainId: SupportedChainId.MAINNET,
hash: '0x0',
blockNumber: 1,
})
......@@ -151,45 +154,47 @@ describe('transaction reducer', () => {
store.dispatch(
addTransaction({
hash: '0x0',
chainId: 4,
chainId: SupportedChainId.MAINNET,
nonce: 3,
info: { type: TransactionType.APPROVAL, spender: '0x0', tokenAddress: '0x0' },
from: '0x0',
})
)
store.dispatch(
checkedTransaction({
chainId: 4,
chainId: SupportedChainId.MAINNET,
hash: '0x0',
blockNumber: 1,
})
)
const tx = store.getState()[4]?.['0x0']
const tx = store.getState()[SupportedChainId.MAINNET]?.['0x0']
expect(tx?.lastCheckedBlockNumber).toEqual(1)
})
it('never decreases', () => {
store.dispatch(
addTransaction({
hash: '0x0',
chainId: 4,
chainId: SupportedChainId.MAINNET,
nonce: 4,
info: { type: TransactionType.APPROVAL, spender: '0x0', tokenAddress: '0x0' },
from: '0x0',
})
)
store.dispatch(
checkedTransaction({
chainId: 4,
chainId: SupportedChainId.MAINNET,
hash: '0x0',
blockNumber: 3,
})
)
store.dispatch(
checkedTransaction({
chainId: 4,
chainId: SupportedChainId.MAINNET,
hash: '0x0',
blockNumber: 1,
})
)
const tx = store.getState()[4]?.['0x0']
const tx = store.getState()[SupportedChainId.MAINNET]?.['0x0']
expect(tx?.lastCheckedBlockNumber).toEqual(3)
})
})
......@@ -198,29 +203,37 @@ describe('transaction reducer', () => {
it('removes all transactions for the chain', () => {
store.dispatch(
addTransaction({
chainId: 1,
chainId: SupportedChainId.MAINNET,
hash: '0x0',
nonce: 5,
info: { type: TransactionType.APPROVAL, spender: 'abc', tokenAddress: 'def' },
from: 'abc',
})
)
store.dispatch(
addTransaction({
chainId: 4,
chainId: SupportedChainId.OPTIMISM,
nonce: 6,
hash: '0x1',
info: { type: TransactionType.APPROVAL, spender: 'abc', tokenAddress: 'def' },
from: 'abc',
})
)
expect(Object.keys(store.getState())).toHaveLength(2)
expect(Object.keys(store.getState())).toEqual([String(1), String(4)])
expect(Object.keys(store.getState()[1] ?? {})).toEqual(['0x0'])
expect(Object.keys(store.getState()[4] ?? {})).toEqual(['0x1'])
store.dispatch(clearAllTransactions({ chainId: 1 }))
expect(Object.keys(store.getState())).toEqual([
String(SupportedChainId.MAINNET),
String(SupportedChainId.OPTIMISM),
])
expect(Object.keys(store.getState()[SupportedChainId.MAINNET] ?? {})).toEqual(['0x0'])
expect(Object.keys(store.getState()[SupportedChainId.OPTIMISM] ?? {})).toEqual(['0x1'])
store.dispatch(clearAllTransactions({ chainId: SupportedChainId.MAINNET }))
expect(Object.keys(store.getState())).toHaveLength(2)
expect(Object.keys(store.getState())).toEqual([String(1), String(4)])
expect(Object.keys(store.getState()[1] ?? {})).toEqual([])
expect(Object.keys(store.getState()[4] ?? {})).toEqual(['0x1'])
expect(Object.keys(store.getState())).toEqual([
String(SupportedChainId.MAINNET),
String(SupportedChainId.OPTIMISM),
])
expect(Object.keys(store.getState()[SupportedChainId.MAINNET] ?? {})).toEqual([])
expect(Object.keys(store.getState()[SupportedChainId.OPTIMISM] ?? {})).toEqual(['0x1'])
})
})
})
import { createSlice } from '@reduxjs/toolkit'
import { SupportedChainId } from 'constants/chains'
import { updateVersion } from '../global/actions'
import { TransactionDetails } from './types'
import { TransactionDetails, TransactionInfo } from './types'
const now = () => new Date().getTime()
......@@ -11,24 +12,40 @@ export interface TransactionState {
}
}
interface AddTransactionPayload {
chainId: SupportedChainId
from: string
hash: string
info: TransactionInfo
nonce: number
}
export const initialState: TransactionState = {}
const transactionSlice = createSlice({
name: 'transactions',
initialState,
reducers: {
addTransaction(transactions, { payload: { chainId, from, hash, info } }) {
addTransaction(
transactions,
{ payload: { chainId, from, hash, info, nonce } }: { payload: AddTransactionPayload }
) {
if (transactions[chainId]?.[hash]) {
throw Error('Attempted to add existing transaction.')
}
const txs = transactions[chainId] ?? {}
txs[hash] = { hash, info, from, addedTime: now() }
txs[hash] = { hash, info, from, addedTime: now(), nonce }
transactions[chainId] = txs
},
clearAllTransactions(transactions, { payload: { chainId } }) {
if (!transactions[chainId]) return
transactions[chainId] = {}
},
removeTransaction(transactions, { payload: { chainId, hash } }) {
if (transactions[chainId][hash]) {
delete transactions[chainId][hash]
}
},
checkedTransaction(transactions, { payload: { chainId, hash, blockNumber } }) {
const tx = transactions[chainId]?.[hash]
if (!tx) {
......@@ -65,6 +82,6 @@ const transactionSlice = createSlice({
},
})
export const { addTransaction, clearAllTransactions, checkedTransaction, finalizeTransaction } =
export const { addTransaction, clearAllTransactions, checkedTransaction, finalizeTransaction, removeTransaction } =
transactionSlice.actions
export default transactionSlice.reducer
......@@ -205,4 +205,5 @@ export interface TransactionDetails {
confirmedTime?: number
from: string
info: TransactionInfo
nonce?: number
}
......@@ -7,7 +7,7 @@ import { useAppDispatch, useAppSelector } from 'state/hooks'
import { L2_CHAIN_IDS } from '../../constants/chains'
import { useAddPopup } from '../application/hooks'
import { checkedTransaction, finalizeTransaction } from './reducer'
import { SerializableTransactionReceipt } from './types'
import { SerializableTransactionReceipt, TransactionDetails } from './types'
export default function Updater() {
const { chainId } = useWeb3React()
......@@ -15,6 +15,13 @@ export default function Updater() {
// speed up popup dismisall time if on L2
const isL2 = Boolean(chainId && L2_CHAIN_IDS.includes(chainId))
const transactions = useAppSelector((state) => state.transactions)
const pendingTransactions = useMemo(() => {
if (!chainId || !transactions[chainId]) return {}
return Object.values(transactions[chainId]).reduce((acc, tx) => {
if (!tx.receipt) acc[tx.hash] = tx
return acc
}, {} as Record<string, TransactionDetails>)
}, [chainId, transactions])
const dispatch = useAppDispatch()
const onCheck = useCallback(
......@@ -52,7 +59,5 @@ export default function Updater() {
[addPopup, dispatch, isL2]
)
const pendingTransactions = useMemo(() => (chainId ? transactions[chainId] ?? {} : {}), [chainId, transactions])
return <LibUpdater pendingTransactions={pendingTransactions} onCheck={onCheck} onReceipt={onReceipt} />
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment