Skip to content
Open
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
65 changes: 56 additions & 9 deletions internal/server/http/scan.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
package http

import (
"context"
"errors"
"github.com/itering/subscan/model"
"github.com/itering/subscan/share/token"
"github.com/itering/subscan/util/address"
"strings"

"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/itering/subscan/model"
"github.com/itering/subscan/plugins"
evmDao "github.com/itering/subscan/plugins/evm/dao"
"github.com/itering/subscan/share/token"
"github.com/itering/subscan/util"
"github.com/itering/subscan/util/address"
)

// @Summary Current network metadata
Expand Down Expand Up @@ -285,11 +289,11 @@ func logsHandle(c *gin.Context) {
}

type checkSearchParams struct {
Hash string `json:"hash" binding:"len=66"`
Hash string `json:"hash" binding:"required"`
}

// checkSearchHashHandle handler check hash type, block or extrinsic or evm tx hash
// @Summary Check hash type
// checkSearchHashHandle handler check search type, block/extrinsic/evm hash or account address
// @Summary Check search type
// @Tags hash
// @Accept json
// @Produce json
Expand All @@ -304,19 +308,62 @@ func checkSearchHashHandle(c *gin.Context) {
}

ctx := c.Request.Context()
search := strings.TrimSpace(p.Hash)

if data := svc.GetBlockByHash(ctx, p.Hash); data != nil {
switch address.DetectSearchType(search) {
case address.SearchTypeEvmAddress:
toJson(c, map[string]string{"hash_type": evmAddressSearchType(ctx, search)}, nil)
return
case address.SearchTypeSubstrateAddress:
toJson(c, map[string]string{"hash_type": address.SearchTypeSubstrateAddress}, nil)
return
case address.SearchTypeHash:
default:
toJson(c, nil, util.ParamsError)
return
}

if data := svc.GetBlockByHash(ctx, search); data != nil {
toJson(c, map[string]string{"hash_type": "block"}, nil)
return
}
if data := svc.GetExtrinsicByHash(ctx, p.Hash); data != nil {
if data := svc.GetExtrinsicByHash(ctx, search); data != nil {
toJson(c, map[string]string{"hash_type": "extrinsic"}, nil)
return
}
// todo evm tx hash
if hashType := evmHashSearchType(ctx, search); hashType != "" {
toJson(c, map[string]string{"hash_type": hashType}, nil)
return
}
toJson(c, nil, util.RecordNotFound)
}

func evmAddressSearchType(ctx context.Context, search string) string {
if evmPluginEnabled() && evmDao.ContractExists(ctx, address.Format(search)) {
return "evm_contract"
}
return address.SearchTypeEvmAddress
}

func evmHashSearchType(ctx context.Context, search string) string {
if !evmPluginEnabled() {
return ""
}
srv := evmDao.ApiSrv{}
if srv.BlockByHash(ctx, search) != nil {
return "evm_block"
}
if srv.GetTransactionByHash(ctx, search) != nil {
return "evm_transaction"
}
return ""
}

func evmPluginEnabled() bool {
evm, ok := plugins.RegisteredPlugins["evm"]
return ok && evm.Enable()
}

// @Summary Get runtime list
// @Description runtimeListHandler get runtime list
// @Tags runtime
Expand Down
5 changes: 5 additions & 0 deletions plugins/evm/dao/contract.go
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,11 @@ func backfillContractFromRuntimeCode(ctx context.Context, contractAddress string
return contract
}

func ContractExists(ctx context.Context, address string) bool {
var contract Contract
return sg.db.WithContext(ctx).Select("address").Where("address = ?", address).Take(&contract).Error == nil
}

func findEventIdentifiers(_ context.Context, abiRaw []byte) []byte {
var abiValue abi.ABI
_ = abiValue.UnmarshalJSON(abiRaw)
Expand Down
24 changes: 19 additions & 5 deletions ui-react/src/__tests__/components/Navbar.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { Navbar } from '@/components/navbar'
import { useRouter } from 'next/router'
import '@testing-library/jest-dom'
Expand All @@ -15,6 +15,7 @@ jest.mock('@/utils/api', () => ({
...jest.requireActual('@/utils/api'),
useMetadata: jest.fn(),
useToken: jest.fn(),
checkSearchHash: jest.fn(),
}))

describe('Navbar', () => {
Expand All @@ -34,9 +35,10 @@ describe('Navbar', () => {
const mockToken = { TST: { price: '100', change: '1' } }

beforeEach(() => {
(useRouter as jest.Mock).mockReturnValue(mockRouter)
;(useRouter as jest.Mock).mockReturnValue(mockRouter)
;(api.useMetadata as jest.Mock).mockReturnValue({ data: { data: mockMetadata } })
;(api.useToken as jest.Mock).mockReturnValue({ data: { data: mockToken } })
;(api.checkSearchHash as jest.Mock).mockResolvedValue({ code: 0, data: { hash_type: 'block' } })
})

afterEach(() => {
Expand All @@ -47,7 +49,7 @@ describe('Navbar', () => {
return render(
<DataProvider>
<Navbar value="" />
</DataProvider>,
</DataProvider>
)
}

Expand All @@ -56,13 +58,25 @@ describe('Navbar', () => {
expect(screen.getByText('Heima Explorer')).toBeInTheDocument()
})

it('handles search with enter key', () => {
it('handles search with enter key', async () => {
renderNavbar()
const searchInput = screen.getByPlaceholderText('Search')

fireEvent.change(searchInput, { target: { value: '123456' } })
fireEvent.keyDown(searchInput, { key: 'Enter' })

expect(mockRouter.push).toHaveBeenCalledWith('/sub/block/123456')
await waitFor(() => expect(mockRouter.push).toHaveBeenCalledWith('/sub/block/123456'))
})

it('auto-detects a substrate account without using the selected block type', async () => {
renderNavbar()
const searchInput = screen.getByPlaceholderText('Search')
const address = '47BHMeKG1Q36gU6WP9ZGiqFhEPF5BhfyTVn9NSaemMd9e9uP'

fireEvent.change(searchInput, { target: { value: address } })
fireEvent.keyDown(searchInput, { key: 'Enter' })

await waitFor(() => expect(mockRouter.push).toHaveBeenCalledWith(`/sub/account/${address}`))
expect(api.checkSearchHash).not.toHaveBeenCalled()
})
})
127 changes: 93 additions & 34 deletions ui-react/src/components/navbar/navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,16 @@ import { useData } from '@/context'
import Image from 'next/image'
import _ from 'lodash'
import { env } from 'next-runtime-env'
import { checkSearchHash } from '@/utils/api'

interface Props extends BareProps {
value: string
}

type SearchType = 'auto' | 'sub_block' | 'sub_extrinsic' | 'sub_event' | 'sub_account' | 'pvm_block' | 'pvm_tx' | 'pvm_contract' | 'pvm_account'

const evmAddressRegex = /^(0x)?[0-9a-fA-F]{40}$/
const substrateAccountRegex = /^[1-9A-HJ-NP-Za-km-z]{32,60}$/
const ChevronDown = ({ fill, size, ...props }: { fill?: string; size?: number | string } & React.SVGProps<SVGSVGElement>) => {
return (
<svg fill="none" height={size || 24} viewBox="0 0 24 24" width={size || 24} xmlns="http://www.w3.org/2000/svg" {...props}>
Expand Down Expand Up @@ -59,7 +65,7 @@ const SearchIcon = ({ size = 24, strokeWidth = 1.5, ...props }) => {
const Component: React.FC<Props> = ({ children, className }) => {
const { metadata, token } = useData()
const [value, setValue] = useState('')
const [type, setType] = useState<string[]>(['sub_block'])
const [type, setType] = useState<string[]>(['auto'])
const router = useRouter()

const showSubstrate = metadata?.enable_substrate
Expand All @@ -73,6 +79,12 @@ const Component: React.FC<Props> = ({ children, className }) => {
search: <SearchIcon fill="none" size={16} />,
}
const typeOptions = useMemo(() => {
const autoOptions = [
{
name: 'Auto Detect',
value: 'auto',
},
]
const subOptions = [
{
name: 'Substrate Block',
Expand Down Expand Up @@ -109,7 +121,7 @@ const Component: React.FC<Props> = ({ children, className }) => {
value: 'pvm_account',
},
]
let options: any[] = []
let options: any[] = [...autoOptions]
if (metadata?.enable_substrate) {
_.forEach(subOptions, (item) => {
options.push({
Expand All @@ -131,45 +143,91 @@ const Component: React.FC<Props> = ({ children, className }) => {

const handleSearch = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleRedirect()
void handleRedirect()
}
}

const routeForSearchType = (searchType: SearchType | '', search: string) => {
switch (searchType) {
case 'sub_block':
return `/sub/block/${search}`
case 'sub_extrinsic':
return `/sub/extrinsic/${search}`
case 'sub_event':
return `/sub/event/${search}`
case 'sub_account':
return `/sub/account/${search}`
case 'pvm_block':
return `/block/${search}`
case 'pvm_tx':
return `/tx/${search}`
case 'pvm_contract':
return `/contract/${search}`
case 'pvm_account':
return `/address/${search}`
default:
return ''
}
}

const routeTypeFromHashType = (hashType: string): SearchType | '' => {
switch (hashType) {
case 'block':
return 'sub_block'
case 'extrinsic':
return 'sub_extrinsic'
case 'address':
return 'sub_account'
case 'evm_block':
return 'pvm_block'
case 'evm_transaction':
return 'pvm_tx'
case 'evm_contract':
return 'pvm_contract'
case 'evm_address':
return 'pvm_account'
default:
return ''
}
}
const handleRedirect = () => {
if (value.trim()) {
switch (type[0]) {
case 'sub_block':
router.push(`/sub/block/${value.trim()}`)
break
case 'sub_extrinsic':
router.push(`/sub/extrinsic/${value.trim()}`)
break
case 'sub_event':
router.push(`/sub/event/${value.trim()}`)
break
case 'sub_account':
router.push(`/sub/account/${value.trim()}`)
break
case 'pvm_block':
router.push(`/block/${value.trim()}`)
break
case 'pvm_tx':
router.push(`/tx/${value.trim()}`)
break
case 'pvm_contract':
router.push(`/contract/${value.trim()}`)
break
case 'pvm_account':
router.push(`/address/${value.trim()}`)
break
default:
break

const detectSearchType = async (search: string): Promise<SearchType | ''> => {
if (showSubstrate !== false && substrateAccountRegex.test(search) && !evmAddressRegex.test(search)) {
return 'sub_account'
}

try {
const detected = await checkSearchHash(env('NEXT_PUBLIC_API_HOST') || '', { hash: search })
if (detected?.code === 0 && detected.data?.hash_type) {
return routeTypeFromHashType(detected.data.hash_type)
}
} catch (error) {
// Keep the manual search selector usable when the backend does not support auto-detection yet.
}

if (showPVM !== false && evmAddressRegex.test(search)) {
return 'pvm_account'
}
return showSubstrate !== false ? 'sub_block' : showPVM ? 'pvm_block' : ''
}

const handleRedirect = async () => {
const search = value.trim()
if (!search) {
return
}

const selectedType = (type[0] || 'auto') as SearchType
const searchType = selectedType === 'auto' ? await detectSearchType(search) : selectedType
const route = routeForSearchType(searchType, search)
if (route) {
router.push(route)
setValue('')
}
}
useEffect(() => {
if (metadata?.enable_evm && !metadata?.enable_substrate) {
setType(['pvm_block'])
setType(['auto'])
}
}, [metadata?.enable_evm, metadata?.enable_substrate])

Expand Down Expand Up @@ -442,6 +500,7 @@ const Component: React.FC<Props> = ({ children, className }) => {
},
}}
label=""
aria-label="Search type"
selectedKeys={type}
onSelectionChange={(key) => {
if (key.currentKey) {
Expand All @@ -455,7 +514,7 @@ const Component: React.FC<Props> = ({ children, className }) => {
<Divider orientation="vertical" className="mx-4" />
</div>
}
endContent={<SearchIcon fill="none" size={24} onClick={handleRedirect} className="mr-3 cursor-pointer" />}
endContent={<SearchIcon fill="none" size={24} onClick={() => void handleRedirect()} className="mr-3 cursor-pointer" />}
/>
</div>
</div>
Expand Down
8 changes: 8 additions & 0 deletions ui-react/src/utils/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ const runtimeFetcher = ([host, url, data]: [string, string, any]) => {
return axiosInstance.post((host || API_HOST) + url, data).then((res) => res.data)
}

export type searchHashType = {
hash_type: string
}

export async function checkSearchHash(host: string, data: { hash: string }): Promise<APIWrapperProps<searchHashType>> {
return runtimeFetcher([host, '/api/scan/check_hash', data])
}

// const postFetcher = ([url, data]: [string, any]) => {
// return axiosInstance.post('/api/proxy', {
// path: url,
Expand Down
Loading
Loading