Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Pending transactions #1954

Merged
merged 12 commits into from
Jul 10, 2024
7 changes: 7 additions & 0 deletions .changelog/1954.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Pending transactions

Introduces a section for pending transactions within the transaction history
interface. It is designed to display transactions currently in a pending
state that are made within the wallet. The section will also show up in case
there is a discrepancy between transaction history nonce and wallet nonce,
indicating that some transactions are currently in pending state.
13 changes: 13 additions & 0 deletions playwright/tests/transfer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,16 @@ test('Scrolling on amount input field should preserve value', async ({ page }) =
await page.mouse.wheel(0, 10)
await expect(input).toHaveValue('1111')
})

test('Should show pending transactions section', async ({ page }) => {
await page.getByTestId('nav-myaccount').click()

await page.getByPlaceholder('Enter an address').fill('oasis1qrf4y7aelwuusc270e8qx04ysr45w3q0zyavrpdk')
await page.getByPlaceholder('Enter an amount').fill('0.1')

await page.getByRole('button', { name: /Send/i }).click()
await page.getByRole('button', { name: /Confirm/i }).click()

await expect(page.getByRole('heading', { name: 'Pending transactions' })).toBeVisible()
await expect(page.getByText('Some transactions are currently in a pending state.')).toBeVisible()
})
18 changes: 18 additions & 0 deletions playwright/utils/mockApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,24 @@ export async function mockApi(context: BrowserContext | Page, balance: number) {
body: 'AAAAAAGggAAAAB5ncnBjLXN0YXR1czowDQpncnBjLW1lc3NhZ2U6DQo=',
})
})
await context.route('**/oasis-core.Consensus/GetSignerNonce', route => {
route.fulfill({
contentType: 'application/grpc-web-text+proto',
body: 'AAAAAAIYKQ==gAAAAB5ncnBjLXN0YXR1czowDQpncnBjLW1lc3NhZ2U6DQo=',
})
})
await context.route('**/oasis-core.Consensus/EstimateGas', route => {
route.fulfill({
contentType: 'application/grpc-web-text+proto',
body: 'AAAAAAMZBPE=gAAAAB5ncnBjLXN0YXR1czowDQpncnBjLW1lc3NhZ2U6DQo=',
})
})
await context.route('**/oasis-core.Consensus/SubmitTx', route => {
route.fulfill({
contentType: 'application/grpc-web-text+proto',
body: 'AAAAAAA=gAAAAB5ncnBjLXN0YXR1czowDQpncnBjLW1lc3NhZ2U6DQo=',
})
})

// Inside Transak iframe
await context.route('https://sentry.io/**', route => route.fulfill({ body: '' }))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { Account, AccountProps } from '../Account'

const props = {
address: 'oasis1qq3xrq0urs8qcffhvmhfhz4p0mu7ewc8rscnlwxe',
balance: { available: '200', debonding: '0', delegations: '800', total: '1000' },
balance: { available: '200', debonding: '0', delegations: '800', total: '1000', nonce: '0' },
onClick: () => {},
isActive: false,
displayBalance: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ describe('<AccountSelector />', () => {
wallets: {
oasis1qq3xrq0urs8qcffhvmhfhz4p0mu7ewc8rscnlwxe: {
address: 'oasis1qq3xrq0urs8qcffhvmhfhz4p0mu7ewc8rscnlwxe',
balance: { available: '100', debonding: '0', delegations: '0', total: '100' },
balance: { available: '100', debonding: '0', delegations: '0', total: '100', nonce: '0' },
publicKey: '00',
type: WalletType.UsbLedger,
},
Expand Down
2 changes: 2 additions & 0 deletions src/app/components/Transaction/__tests__/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ describe('<Transaction />', () => {
runtimeName: undefined,
runtimeId: undefined,
round: undefined,
nonce: 0n.toString(),
},
network,
)
Expand All @@ -131,6 +132,7 @@ describe('<Transaction />', () => {
runtimeName: undefined,
runtimeId: undefined,
round: undefined,
nonce: 0n.toString(),
},
network,
)
Expand Down
36 changes: 27 additions & 9 deletions src/app/components/Transaction/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -462,9 +462,11 @@ export function Transaction(props: TransactionProps) {
</Box>
lubej marked this conversation as resolved.
Show resolved Hide resolved

<Box pad="none">
<InfoBox icon={Clock} label={t('common.time', 'Time')}>
{intlDateTimeFormat(transaction.timestamp!)}
</InfoBox>
{transaction.timestamp && (
<InfoBox icon={Clock} label={t('common.time', 'Time')}>
{intlDateTimeFormat(transaction.timestamp)}
</InfoBox>
)}

{!transaction.runtimeId && transaction.level && (
<InfoBox icon={Cube} label={t('common.block', 'Block')}>
Expand All @@ -486,15 +488,31 @@ export function Transaction(props: TransactionProps) {
<AmountFormatter amount={transaction.amount!} smallTicker />
</Text>
<Text
color={transaction.status === TransactionStatus.Successful ? 'successful-label' : 'status-error'}
color={(() => {
switch (transaction.status) {
case TransactionStatus.Successful:
return 'successful-label'
case TransactionStatus.Pending:
return 'status-warning'
case TransactionStatus.Failed:
default:
return 'status-error'
}
})()}
size="small"
weight="bold"
>
{transaction.status === TransactionStatus.Successful ? (
<span>{t('account.transaction.successful', 'Successful')}</span>
) : (
<span>{t('account.transaction.failed', 'Failed')}</span>
)}
{(() => {
switch (transaction.status) {
case TransactionStatus.Successful:
return <span>{t('account.transaction.successful', 'Successful')}</span>
case TransactionStatus.Pending:
return <span>{t('account.transaction.pending', 'Pending')}</span>
case TransactionStatus.Failed:
default:
return <span>{t('account.transaction.failed', 'Failed')}</span>
}
})()}
</Text>
</Box>
</StyledCardBody>
Expand Down
2 changes: 1 addition & 1 deletion src/app/lib/getAccountBalanceWithFallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@ function* getBalanceGRPC(address: string) {
delegations: null,
debonding: null,
total: null,
nonce: account.general?.nonce?.toString() ?? '0',
}
}

export function* getAccountBalanceWithFallback(address: string) {
const { getAccount } = yield* call(getExplorerAPIs)

try {
const account: Account = yield* call(getAccount, address)
return account
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,11 +1,171 @@
import * as React from 'react'
import { render } from '@testing-library/react'
import { render, screen } from '@testing-library/react'

import { TransactionHistory } from '..'
import { configureAppStore } from '../../../../../../store/configureStore'
import { Provider, useDispatch } from 'react-redux'
import { DeepPartialRootState, RootState } from '../../../../../../types/RootState'
import { Transaction, TransactionStatus, TransactionType } from 'app/state/transaction/types'

jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useDispatch: jest.fn(),
}))

const renderCmp = (store: ReturnType<typeof configureAppStore>) =>
render(
<Provider store={store}>
<TransactionHistory />
</Provider>,
)

const getPendingTx = (hash: string): Transaction => ({
hash,
type: TransactionType.StakingTransfer,
from: 'oasis1qz0k5q8vjqvu4s4nwxyj406ylnflkc4vrcjghuwk',
amount: 1n.toString(),
to: 'oasis1qz0k5q8vjqvu4s4nwxyj406ylnflkc4vrcjghuww',
status: undefined,
fee: undefined,
level: undefined,
round: undefined,
runtimeId: undefined,
runtimeName: undefined,
timestamp: undefined,
nonce: undefined,
})

const getTx = ({ hash = '', nonce = 0n, status = TransactionStatus.Successful } = {}): Transaction => ({
...getPendingTx(hash),
status,
nonce: nonce.toString(),
})

const getState = ({
accountNonce = 0n,
pendingLocalTxs = [],
accountTxs = [],
}: { accountNonce?: bigint; pendingLocalTxs?: Transaction[]; accountTxs?: Transaction[] } = {}) => {
const state: DeepPartialRootState = {
account: {
loading: false,
address: 'oasis1qz0k5q8vjqvu4s4nwxyj406ylnflkc4vrcjghuwk',
available: 100000000000n.toString(),
delegations: null,
debonding: null,
total: null,
transactions: [...accountTxs],
accountError: undefined,
transactionsError: undefined,
pendingTransactions: {
local: [...pendingLocalTxs],
testnet: [],
mainnet: [],
},
nonce: accountNonce.toString(),
},
staking: {
delegations: [],
debondingDelegations: [],
},
wallet: {
selectedWallet: 'oasis1qz0k5q8vjqvu4s4nwxyj406ylnflkc4vrcjghuwk',
wallets: {
oasis1qz0k5q8vjqvu4s4nwxyj406ylnflkc4vrcjghuwk: {
address: 'oasis1qz0k5q8vjqvu4s4nwxyj406ylnflkc4vrcjghuwk',
},
},
},
}
return configureAppStore(state as Partial<RootState>)
}

describe('<TransactionHistory />', () => {
it.skip('should match snapshot', () => {
const component = render(<TransactionHistory />)
expect(component.container.firstChild).toMatchSnapshot()
beforeEach(() => {
// Ignore dispatches to fetch account from AccountPage
jest.mocked(useDispatch).mockImplementation(() => jest.fn())
})

it('should not display any pending or completed txs', async () => {
renderCmp(getState())

expect(() => screen.getByTestId('pending-txs')).toThrow()
expect(() => screen.getByTestId('completed-txs')).toThrow()

expect(screen.queryByText('account.summary.someTxsInPendingState')).not.toBeInTheDocument()
expect(await screen.findByText('account.summary.noTransactionFound')).toBeInTheDocument()
})

it('should display pending txs alert and no transactions', async () => {
renderCmp(getState({ accountNonce: 1n }))

expect(() => screen.getByTestId('pending-txs')).toThrow()
expect(() => screen.getByTestId('completed-txs')).toThrow()

expect(await screen.findByText('account.summary.someTxsInPendingState')).toBeInTheDocument()
expect(await screen.findByText('account.summary.noTransactionFound')).toBeInTheDocument()
expect(await screen.findByRole('link')).toHaveAttribute(
'href',
'http://localhost:9001/data/accounts/detail/oasis1qz0k5q8vjqvu4s4nwxyj406ylnflkc4vrcjghuwk',
)
})

it('should display pending txs alert with single pending tx and no completed transactions', async () => {
renderCmp(getState({ accountNonce: 0n, pendingLocalTxs: [getPendingTx('txHash1')] }))

expect(screen.getByTestId('pending-txs').childElementCount).toBe(1)
expect(() => screen.getByTestId('completed-txs')).toThrow()

expect(await screen.findByText('account.summary.someTxsInPendingState')).toBeInTheDocument()
expect(await screen.findByText('account.summary.noTransactionFound')).toBeInTheDocument()
expect(await screen.findByText('txHash1')).toBeInTheDocument()
})

it('should display single pending and completed tx', async () => {
renderCmp(
getState({
accountNonce: 2n,
accountTxs: [getTx({ hash: 'txHash1', nonce: 0n })],
pendingLocalTxs: [getPendingTx('txHash2')],
}),
)

expect(screen.getByTestId('pending-txs').childElementCount).toBe(1)
expect(screen.getByTestId('completed-txs').childElementCount).toBe(1)

expect(await screen.findByText('txHash1')).toBeInTheDocument()
expect(await screen.findByText('txHash2')).toBeInTheDocument()
})

it('should not display pending section in case of failed tx', async () => {
renderCmp(
getState({
accountNonce: 1n,
accountTxs: [getTx({ hash: 'txHash1', nonce: 0n, status: TransactionStatus.Failed })],
}),
)

expect(() => screen.getByTestId('pending-txs')).toThrow()
expect(screen.getByTestId('completed-txs').childElementCount).toBe(1)

expect(await screen.findByText('txHash1')).toBeInTheDocument()

expect(() => screen.getByText('account.summary.someTxsInPendingState')).toThrow()
})

it('should not display pending section on initial load', async () => {
renderCmp(
getState({
accountNonce: 1n,
accountTxs: [getTx({ hash: 'txHash1', nonce: 0n, status: TransactionStatus.Successful })],
}),
)

expect(() => screen.getByTestId('pending-txs')).toThrow()
expect(screen.getByTestId('completed-txs').childElementCount).toBe(1)

expect(await screen.findByText('txHash1')).toBeInTheDocument()

expect(() => screen.getByText('account.summary.someTxsInPendingState')).toThrow()
})
})
Loading
Loading