From 06a010e606d5eefd120b625e1dd19df67eb1cf9f Mon Sep 17 00:00:00 2001 From: CrossAgent Date: Tue, 19 May 2026 17:20:28 +0800 Subject: [PATCH 1/2] Auto detect search input type --- internal/server/http/scan.go | 65 +++++++++++++++++++++++++++++++----- plugins/evm/dao/contract.go | 5 +++ util/address/address.go | 22 ++++++++++++ util/address/address_test.go | 9 +++++ 4 files changed, 92 insertions(+), 9 deletions(-) diff --git a/internal/server/http/scan.go b/internal/server/http/scan.go index f066bce..8cf8c2e 100644 --- a/internal/server/http/scan.go +++ b/internal/server/http/scan.go @@ -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 @@ -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 @@ -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 diff --git a/plugins/evm/dao/contract.go b/plugins/evm/dao/contract.go index 0792646..9b3f9a1 100644 --- a/plugins/evm/dao/contract.go +++ b/plugins/evm/dao/contract.go @@ -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) diff --git a/util/address/address.go b/util/address/address.go index 7c4c476..9edac6b 100644 --- a/util/address/address.go +++ b/util/address/address.go @@ -10,6 +10,13 @@ import ( var ( ethAddressRegex = regexp.MustCompile(`^0x[0-9a-fA-F]{40}$`) substrateAddressRegex = regexp.MustCompile(`^[0-9a-fA-F]{64}$`) + hashRegex = regexp.MustCompile(`^0x[0-9a-fA-F]{64}$`) +) + +const ( + SearchTypeHash = "hash" + SearchTypeEvmAddress = "evm_address" + SearchTypeSubstrateAddress = "address" ) // SS58Address converts the address to SS58 format @@ -35,6 +42,21 @@ func VerifySubstrateAddress(accountId string) bool { return substrateAddressRegex.MatchString(util.TrimHex(accountId)) } +// DetectSearchType returns the obvious search target type without touching storage. +func DetectSearchType(search string) string { + search = strings.TrimSpace(search) + switch { + case VerifyEthereumAddress(search): + return SearchTypeEvmAddress + case hashRegex.MatchString(search): + return SearchTypeHash + case VerifySubstrateAddress(search), ss58.Decode(search) != "": + return SearchTypeSubstrateAddress + default: + return "" + } +} + // Decode converts the address to Substrate public key or Ethereum format, depending on the address type Ethereum or Substrate func Decode(address string) string { if VerifyEthereumAddress(address) { diff --git a/util/address/address_test.go b/util/address/address_test.go index ec96dc0..20382ab 100644 --- a/util/address/address_test.go +++ b/util/address/address_test.go @@ -53,3 +53,12 @@ func TestFormat(t *testing.T) { assert.Equal(t, Format("0x3a370c6e4af506123c30e091a1cbfbc3728e1ec5"), "0x3a370c6e4af506123c30e091a1cbfbc3728e1ec5") assert.Equal(t, Format("3a370c6e4af506123c30e091a1cbfbc3728e1ec5"), "0x3a370c6e4af506123c30e091a1cbfbc3728e1ec5") } + +func TestDetectSearchType(t *testing.T) { + assert.Equal(t, SearchTypeHash, DetectSearchType("0xbadc6963e1add4d7a588e350d837579491d08bb270f02c56b3dd5f17018dee0c")) + assert.Equal(t, SearchTypeEvmAddress, DetectSearchType("0x3a370c6e4af506123c30e091a1cbfbc3728e1ec5")) + assert.Equal(t, SearchTypeEvmAddress, DetectSearchType("3a370c6e4af506123c30e091a1cbfbc3728e1ec5")) + assert.Equal(t, SearchTypeSubstrateAddress, DetectSearchType("12KL8YptX9SuUCZGrsNrSRzp3zHNqbwLqmfN8vubtj1z1Bqv")) + assert.Equal(t, SearchTypeSubstrateAddress, DetectSearchType("3a370c6e4af506123c30e091a1cbfbc3728e1ec5fc47d87457fbb0b504903260")) + assert.Equal(t, "", DetectSearchType("not-an-address")) +} From 094c70c8ceba056e5a40744c1abc3156ea2ee0d1 Mon Sep 17 00:00:00 2001 From: CrossAgent Date: Mon, 25 May 2026 22:54:51 +0800 Subject: [PATCH 2/2] Auto-detect navbar search type --- .../src/__tests__/components/Navbar.test.tsx | 24 +++- ui-react/src/components/navbar/navbar.tsx | 127 +++++++++++++----- ui-react/src/utils/api.ts | 8 ++ 3 files changed, 120 insertions(+), 39 deletions(-) diff --git a/ui-react/src/__tests__/components/Navbar.test.tsx b/ui-react/src/__tests__/components/Navbar.test.tsx index 41d1051..0ef924b 100644 --- a/ui-react/src/__tests__/components/Navbar.test.tsx +++ b/ui-react/src/__tests__/components/Navbar.test.tsx @@ -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' @@ -15,6 +15,7 @@ jest.mock('@/utils/api', () => ({ ...jest.requireActual('@/utils/api'), useMetadata: jest.fn(), useToken: jest.fn(), + checkSearchHash: jest.fn(), })) describe('Navbar', () => { @@ -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(() => { @@ -47,7 +49,7 @@ describe('Navbar', () => { return render( - , + ) } @@ -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() }) }) diff --git a/ui-react/src/components/navbar/navbar.tsx b/ui-react/src/components/navbar/navbar.tsx index 193718b..a6e7d95 100644 --- a/ui-react/src/components/navbar/navbar.tsx +++ b/ui-react/src/components/navbar/navbar.tsx @@ -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) => { return ( @@ -59,7 +65,7 @@ const SearchIcon = ({ size = 24, strokeWidth = 1.5, ...props }) => { const Component: React.FC = ({ children, className }) => { const { metadata, token } = useData() const [value, setValue] = useState('') - const [type, setType] = useState(['sub_block']) + const [type, setType] = useState(['auto']) const router = useRouter() const showSubstrate = metadata?.enable_substrate @@ -73,6 +79,12 @@ const Component: React.FC = ({ children, className }) => { search: , } const typeOptions = useMemo(() => { + const autoOptions = [ + { + name: 'Auto Detect', + value: 'auto', + }, + ] const subOptions = [ { name: 'Substrate Block', @@ -109,7 +121,7 @@ const Component: React.FC = ({ children, className }) => { value: 'pvm_account', }, ] - let options: any[] = [] + let options: any[] = [...autoOptions] if (metadata?.enable_substrate) { _.forEach(subOptions, (item) => { options.push({ @@ -131,45 +143,91 @@ const Component: React.FC = ({ children, className }) => { const handleSearch = (e: KeyboardEvent) => { 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 => { + 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]) @@ -442,6 +500,7 @@ const Component: React.FC = ({ children, className }) => { }, }} label="" + aria-label="Search type" selectedKeys={type} onSelectionChange={(key) => { if (key.currentKey) { @@ -455,7 +514,7 @@ const Component: React.FC = ({ children, className }) => { } - endContent={} + endContent={ void handleRedirect()} className="mr-3 cursor-pointer" />} /> diff --git a/ui-react/src/utils/api.ts b/ui-react/src/utils/api.ts index 29c5d06..7bc54a5 100644 --- a/ui-react/src/utils/api.ts +++ b/ui-react/src/utils/api.ts @@ -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> { + return runtimeFetcher([host, '/api/scan/check_hash', data]) +} + // const postFetcher = ([url, data]: [string, any]) => { // return axiosInstance.post('/api/proxy', { // path: url,