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

feat: add CTID support #839

Merged
merged 12 commits into from
Nov 15, 2023
3 changes: 2 additions & 1 deletion public/locales/en-US/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,8 @@
"transaction_empty_title": "No transaction hash supplied",
"transaction_empty_hint": "Enter a transaction hash in the search box",
"validator_not_found": "Validator not found",
"check_transaction_hash": "Please check your transaction hash",
"check_transaction_hash": "Please check your transaction hash or CTID.",
"wrong_network": "This CTID applies to a different network.",
ckniffen marked this conversation as resolved.
Show resolved Hide resolved
"check_validator_key": "Please check your validator key",
"transaction": "Transaction",
"success": "Success",
Expand Down
1 change: 1 addition & 0 deletions public/locales/es-ES/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@
"transaction_empty_hint": "Introduce un hash de transacción en la caja de búsqueda",
"validator_not_found": "Validador no encontrado",
"check_transaction_hash": "Por favor, comprueba tu hash de transacción",
"wrong_network": null,
"check_validator_key": "Por favor, comprueba la clave de tu validador",
"transaction": "Transacción",
"success": "Éxito",
Expand Down
1 change: 1 addition & 0 deletions public/locales/fr-FR/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@
"transaction_empty_hint": "Entrez un hash de transaction dans la zone de recherche",
"validator_not_found": "Validateur non trouvé",
"check_transaction_hash": "Veuillez vérifier le hash de la transaction",
"wrong_network": null,
"check_validator_key": "Veuillez vérifier la clé du validateur",
"transaction": "Transaction",
"success": "Succès",
Expand Down
1 change: 1 addition & 0 deletions public/locales/ja-JP/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@
"transaction_empty_hint": "検索欄にトランザクションハッシュを入力してください",
"validator_not_found": "バリデータが見つかりません",
"check_transaction_hash": "トランザクションのハッシュ値を確認してください",
"wrong_network": null,
"check_validator_key": "バリデータのキーを確認してください",
"transaction": "トランザクション",
"success": "成功",
Expand Down
1 change: 1 addition & 0 deletions public/locales/ko-KR/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@
"transaction_empty_hint": "검색창에 트랜잭션 해시를 입력해 주세요",
"validator_not_found": "검증자를 찾을 수 없습니다",
"check_transaction_hash": "트랜잭션 해시를 확인해 주세요",
"wrong_network": null,
"check_validator_key": "검증자 키를 확인해주세요",
"transaction": "트랜잭션",
"success": "성공",
Expand Down
7 changes: 7 additions & 0 deletions src/containers/Header/Search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
FULL_CURRENCY_REGEX,
HASH_REGEX,
VALIDATORS_REGEX,
CTID_REGEX,
} from '../shared/utils'
import './search.scss'
import { isValidPayString } from '../../rippled/payString'
Expand Down Expand Up @@ -109,6 +110,12 @@ const getRoute = async (
path: buildPath(VALIDATOR_ROUTE, { identifier: normalizeAccount(id) }),
}
}
if (CTID_REGEX.test(id)) {
return {
type: 'transactions',
path: buildPath(TRANSACTION_ROUTE, { identifier: id.toUpperCase() }),
}
}

return null
}
Expand Down
144 changes: 49 additions & 95 deletions src/containers/Header/test/Search.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,16 @@ describe('Search component', () => {
)
}

const oldEnvs = process.env

beforeEach(() => {
process.env = { ...oldEnvs, VITE_ENVIRONMENT: 'mainnet' }
})

afterEach(() => {
process.env = oldEnvs
})

it('renders without crashing', () => {
const wrapper = createWrapper()
wrapper.unmount()
Expand Down Expand Up @@ -53,6 +63,7 @@ describe('Search component', () => {

const hash =
'59239EA78084F6E2F288473F8AE02F3E6FC92F44BDE59668B5CAE361D3D32838'
const ctid = 'C0BF433500020000'
const token1 = 'cny.rJ1adrpGS3xsnQMb9Cw54tWJVFPuSdZHK'
const token1VariantPlus = 'cny.rJ1adrpGS3xsnQMb9Cw54tWJVFPuSdZHK'
const token1VariantMinus = 'cny-rJ1adrpGS3xsnQMb9Cw54tWJVFPuSdZHK'
Expand All @@ -74,140 +85,83 @@ describe('Search component', () => {
// mock getNFTInfo api to test transactions and nfts
const mockAPI = jest.spyOn(rippled, 'getTransaction')

const testValue = async (searchInput, expectedPath) => {
input.instance().value = searchInput
input.simulate('keyDown', { key: 'Enter' })
await flushPromises()
expect(window.location.pathname).toEqual(expectedPath)
}

input.simulate('keyDown', { key: 'a' })
expect(window.location.pathname).toEqual('/')

input.instance().value = ledgerIndex
input.simulate('keyDown', { key: 'Enter' })
await flushPromises()
expect(window.location.pathname).toEqual(`/ledgers/${ledgerIndex}`)
await testValue(ledgerIndex, `/ledgers/${ledgerIndex}`)

input.instance().value = rippleAddress
input.simulate('keyDown', { key: 'Enter' })
await flushPromises()
expect(window.location.pathname).toEqual(`/accounts/${rippleAddress}`)
await testValue(rippleAddress, `/accounts/${rippleAddress}`)

input.instance().value = addressWithQuotes
input.simulate('keyDown', { key: 'Enter' })
await flushPromises()
expect(window.location.pathname).toEqual(`/accounts/${rippleAddress}`)
await testValue(addressWithQuotes, `/accounts/${rippleAddress}`)

input.instance().value = addressWithSingleQuote
input.simulate('keyDown', { key: 'Enter' })
await flushPromises()
expect(window.location.pathname).toEqual(`/accounts/${rippleAddress}`)
await testValue(addressWithSingleQuote, `/accounts/${rippleAddress}`)

input.instance().value = addressWithSpace
input.simulate('keyDown', { key: 'Enter' })
await flushPromises()
expect(window.location.pathname).toEqual(`/accounts/${rippleAddress}`)
await testValue(addressWithSpace, `/accounts/${rippleAddress}`)

input.instance().value = rippleXAddress
input.simulate('keyDown', { key: 'Enter' })
await flushPromises()
expect(window.location.pathname).toEqual(`/accounts/${rippleXAddress}`)
await testValue(rippleXAddress, `/accounts/${rippleXAddress}`)

// Normalize split address format to a X-Address
input.instance().value = rippleSplitAddress
input.simulate('keyDown', { key: 'Enter' })
await flushPromises()
expect(window.location.pathname).toEqual(`/accounts/${rippleXAddress}`)
await testValue(rippleSplitAddress, `/accounts/${rippleXAddress}`)

input.instance().value = paystring
input.simulate('keyDown', { key: 'Enter' })
await flushPromises()
expect(window.location.pathname).toEqual(`/paystrings/${paystring}`)
await testValue(paystring, `/paystrings/${paystring}`)

// Normalize paystrings with @ to $
input.instance().value = paystringWithAt
input.simulate('keyDown', { key: 'Enter' })
await flushPromises()
expect(window.location.pathname).toEqual(`/paystrings/${paystring}`)
await testValue(paystringWithAt, `/paystrings/${paystring}`)

// Validator
input.instance().value = validator
input.simulate('keyDown', { key: 'Enter' })
await flushPromises()
expect(window.location.pathname).toEqual(`/validators/${validator}`)
await testValue(validator, `/validators/${validator}`)

mockAPI.mockImplementation(() => {
'123'
})
input.instance().value = hash
input.simulate('keyDown', { key: 'Enter' })
await flushPromises()
expect(window.location.pathname).toEqual(`/transactions/${hash}`)
await testValue(hash, `/transactions/${hash}`)

input.instance().value = token1
input.simulate('keyDown', { key: 'Enter' })
await flushPromises()
expect(window.location.pathname).toEqual(`/token/${token1}`)
await testValue(ctid, `/transactions/${ctid}`)

await testValue(token1, `/token/${token1}`)

// testing multiple variants of token format
input.instance().value = token1VariantColon
input.simulate('keyDown', { key: 'Enter' })
await flushPromises()
expect(window.location.pathname).toEqual(`/token/${token1}`)

input.instance().value = token1VariantPlus
input.simulate('keyDown', { key: 'Enter' })
await flushPromises()
expect(window.location.pathname).toEqual(`/token/${token1}`)

input.instance().value = token1VariantMinus
input.simulate('keyDown', { key: 'Enter' })
await flushPromises()
expect(window.location.pathname).toEqual(`/token/${token1}`)

input.instance().value = token2
input.simulate('keyDown', { key: 'Enter' })
await flushPromises()
expect(window.location.pathname).toEqual(`/token/${token2}`)
await testValue(token1VariantColon, `/token/${token1}`)

await testValue(token1VariantPlus, `/token/${token1}`)

await testValue(token1VariantMinus, `/token/${token1}`)

await testValue(token2, `/token/${token2}`)

// testing multiple variants of full token format
input.instance().value = token2VariantColon
input.simulate('keyDown', { key: 'Enter' })
await flushPromises()
expect(window.location.pathname).toEqual(`/token/${token2}`)
await testValue(token2VariantColon, `/token/${token2}`)

input.instance().value = token2VariantPlus
input.simulate('keyDown', { key: 'Enter' })
await flushPromises()
expect(window.location.pathname).toEqual(`/token/${token2}`)
await testValue(token2VariantPlus, `/token/${token2}`)

input.instance().value = token2VariantMinus
input.simulate('keyDown', { key: 'Enter' })
await flushPromises()
expect(window.location.pathname).toEqual(`/token/${token2}`)
await testValue(token2VariantMinus, `/token/${token2}`)

// Returns a response upon a valid nft_id, redirect to NFT
mockAPI.mockImplementation(() => {
throw new Error('Tx not found', 404)
})
input.instance().value = nftoken
input.simulate('keyDown', { key: 'Enter' })
await flushPromises()
expect(window.location.pathname).toEqual(`/nft/${nftoken}`)
await testValue(nftoken, `/nft/${nftoken}`)

input.instance().value = invalidString
input.simulate('keyDown', { key: 'Enter' })
await flushPromises()
expect(window.location.pathname).toEqual(`/search/${invalidString}`)
await testValue(invalidString, `/search/${invalidString}`)

// ensure strings are trimmed
mockAPI.mockImplementation(() => {
'123'
})
input.instance().value = ` ${hash} `
input.simulate('keyDown', { key: 'Enter' })
await flushPromises()
expect(window.location.pathname).toEqual(`/transactions/${hash}`)
await testValue(` ${hash} `, `/transactions/${hash}`)

// handle lower case tx hash
input.instance().value = hash.toLowerCase()
input.simulate('keyDown', { key: 'Enter' })
await flushPromises()
expect(window.location.pathname).toEqual(`/transactions/${hash}`)
await testValue(hash.toLowerCase(), `/transactions/${hash}`)

// handle lower case ctid
await testValue(ctid.toLowerCase(), `/transactions/${ctid}`)
wrapper.unmount()
})

Expand Down
45 changes: 29 additions & 16 deletions src/containers/Transactions/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { useWindowSize } from 'usehooks-ts'
import NoMatch from '../NoMatch'
import { Loader } from '../shared/components/Loader'
import { Tabs } from '../shared/components/Tabs'
import { NOT_FOUND, BAD_REQUEST, HASH_REGEX } from '../shared/utils'
import { NOT_FOUND, BAD_REQUEST, HASH_REGEX, CTID_REGEX } from '../shared/utils'
import { SimpleTab } from './SimpleTab'
import { DetailTab } from './DetailTab'
import './transaction.scss'
Expand All @@ -20,6 +20,8 @@ import { SUCCESSFUL_TRANSACTION } from '../shared/transactionUtils'
import { getTransaction } from '../../rippled'
import { TRANSACTION_ROUTE } from '../App/routes'

const WRONG_NETWORK = 406

const ERROR_MESSAGES: Record<string, { title: string; hints: string[] }> = {}
ERROR_MESSAGES[NOT_FOUND] = {
title: 'transaction_not_found',
Expand All @@ -29,6 +31,10 @@ ERROR_MESSAGES[BAD_REQUEST] = {
title: 'invalid_transaction_hash',
hints: ['check_transaction_hash'],
}
ERROR_MESSAGES[WRONG_NETWORK] = {
title: 'wrong_network',
hints: ['check_transaction_hash'],
}
ERROR_MESSAGES.default = {
title: 'generic_error',
hints: ['not_your_fault'],
Expand All @@ -48,22 +54,22 @@ export const Transaction = () => {
if (identifier === '') {
return undefined
}
if (!HASH_REGEX.test(identifier)) {
return Promise.reject(BAD_REQUEST)
if (HASH_REGEX.test(identifier) || CTID_REGEX.test(identifier)) {
return getTransaction(identifier, rippledSocket).catch(
(transactionRequestError) => {
const status = transactionRequestError.code
trackException(
`transaction ${identifier} --- ${JSON.stringify(
transactionRequestError.message,
)}`,
)

return Promise.reject(status)
},
)
}

return getTransaction(identifier, rippledSocket).catch(
(transactionRequestError) => {
const status = transactionRequestError.code
trackException(
`transaction ${identifier} --- ${JSON.stringify(
transactionRequestError.message,
)}`,
)

return Promise.reject(status)
},
)
return Promise.reject(BAD_REQUEST)
},
)
const { width } = useWindowSize()
Expand Down Expand Up @@ -93,9 +99,16 @@ export const Transaction = () => {
<div className="summary">
<div className="type">{type}</div>
<TxStatus status={data?.raw.meta.TransactionResult} />
<div className="hash" title={data?.raw.hash}>
<div className="txid" title={data?.raw.hash}>
<div className="title">{t('hash')}: </div>
{data?.raw.hash}
</div>
{data?.raw.tx.ctid && (
<div className="txid" title={data.raw.tx.ctid}>
<div className="title">CTID: </div>
{data.raw.tx.ctid}
</div>
)}
</div>
)
}
Expand Down
Loading