From 3596da719ef784ee8c2d79c3ab7eddbaed56b14c Mon Sep 17 00:00:00 2001 From: Steven Date: Tue, 26 May 2026 01:13:01 +0800 Subject: [PATCH 01/11] [fix] refresh balance --- plugins/balance/balance.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/plugins/balance/balance.go b/plugins/balance/balance.go index 448076d..18386c8 100644 --- a/plugins/balance/balance.go +++ b/plugins/balance/balance.go @@ -9,6 +9,7 @@ import ( "github.com/itering/subscan/plugins/balance/http" "github.com/itering/subscan/plugins/balance/model" "github.com/itering/subscan/plugins/balance/service" + "github.com/itering/subscan/util/address" "github.com/shopspring/decimal" "github.com/urfave/cli" "strings" @@ -37,6 +38,20 @@ func (a *Balance) Commands() []cli.Command { return nil }, }, + { + Name: "RefreshAccount", + Usage: "Refresh one account balance from chain storage", + Flags: []cli.Flag{ + cli.StringFlag{Name: "address", Usage: "account SS58 address or account id"}, + }, + Action: func(c *cli.Context) error { + account := c.String("address") + if account == "" { + return cli.NewExitError("address is required", 1) + } + return dao.RefreshAccount(context.Background(), a.storage(), address.Decode(account)) + }, + }, { Name: "InitTransfer", Action: func(c *cli.Context) error { From 42bb8bc420718a9ff0a8a0e9022d565780c9077f Mon Sep 17 00:00:00 2001 From: Steven Date: Tue, 26 May 2026 01:34:51 +0800 Subject: [PATCH 02/11] [fix] fix Locked --- plugins/balance/model/model.go | 4 ++-- plugins/balance/model/model_test.go | 12 ++++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/plugins/balance/model/model.go b/plugins/balance/model/model.go index 5809908..5f45cb4 100644 --- a/plugins/balance/model/model.go +++ b/plugins/balance/model/model.go @@ -64,8 +64,8 @@ func AccountLockSummary(accountData *AccountData, locks []BalanceLock) LockSumma type VestingInfo struct { Locked decimal.Decimal `json:"locked"` - PerBlock decimal.Decimal `json:"perBlock"` - StartingBlock uint64 `json:"startingBlock"` + PerBlock decimal.Decimal `json:"per_block"` + StartingBlock uint64 `json:"starting_block"` } func (v VestingInfo) VestedAt(blockNum uint64) decimal.Decimal { diff --git a/plugins/balance/model/model_test.go b/plugins/balance/model/model_test.go index 059d001..aa1f286 100644 --- a/plugins/balance/model/model_test.go +++ b/plugins/balance/model/model_test.go @@ -1,6 +1,7 @@ package model import ( + "encoding/json" "testing" "github.com/shopspring/decimal" @@ -44,6 +45,17 @@ func TestSummarizeVestingCalculatesVestedAmount(t *testing.T) { assert.Equal(t, "58", summary.String()) } +func TestVestingInfoDecodesChainStorageFields(t *testing.T) { + var schedules []VestingInfo + err := json.Unmarshal([]byte(`[{"locked":"24102589000000000000000000","per_block":"5555555555555555000","starting_block":6282695}]`), &schedules) + + assert.NoError(t, err) + assert.Len(t, schedules, 1) + assert.Equal(t, "24102589000000000000000000", schedules[0].Locked.String()) + assert.Equal(t, "5555555555555555000", schedules[0].PerBlock.String()) + assert.Equal(t, uint64(6282695), schedules[0].StartingBlock) +} + func TestVestingInfoVestedAtCapsAtLocked(t *testing.T) { schedule := VestingInfo{ Locked: decimal.NewFromInt(100), From bb1a1047646e7021298704bb48fc66e7a7939d36 Mon Sep 17 00:00:00 2001 From: "crossagent-production-app[bot]" <283591059+crossagent-production-app[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 10:16:57 +0800 Subject: [PATCH 03/11] Improve large balance amount display (#41) * Improve balance amount formatting * Show native token symbol on compact balances --------- Co-authored-by: CrossAgent --- ui-react/src/__tests__/utils/text.test.ts | 15 ++++++++++ .../src/components/account/accountTable.tsx | 4 +-- .../erc20Token/tokenHolderTable.tsx | 4 +-- .../erc20Token/tokenTransferTable.tsx | 4 +-- .../components/pvmAccount/accountTable.tsx | 4 +-- .../src/components/pvmAccount/tokenTable.tsx | 4 +-- .../src/components/transfer/transferTable.tsx | 4 +-- ui-react/src/components/tx/txTable.tsx | 4 +-- ui-react/src/pages/address/[id].tsx | 4 +-- ui-react/src/pages/contract/[id].tsx | 4 +-- ui-react/src/pages/sub/account/[id].tsx | 28 +++++++++++++---- ui-react/src/pages/token/[id].tsx | 4 +-- ui-react/src/pages/tx/[id].tsx | 8 ++--- ui-react/src/utils/text.ts | 30 +++++++++++++++++++ 14 files changed, 91 insertions(+), 30 deletions(-) diff --git a/ui-react/src/__tests__/utils/text.test.ts b/ui-react/src/__tests__/utils/text.test.ts index 3ad32e9..3609b27 100644 --- a/ui-react/src/__tests__/utils/text.test.ts +++ b/ui-react/src/__tests__/utils/text.test.ts @@ -2,6 +2,7 @@ import BigNumber from 'bignumber.js'; import { getThemeColor, checkIsExtrinsicIndex, + formatBalanceAmount, formatHash, formatNumber, getBalanceAmount, @@ -92,6 +93,20 @@ describe('Utils - text', () => { }); }); + describe('formatBalanceAmount', () => { + it('should compact million-scale balances', () => { + expect(formatBalanceAmount(new BigNumber('12064779'))).toBe('12.0648 Million'); + }); + + it('should keep ordinary balances readable', () => { + expect(formatBalanceAmount(new BigNumber('255969555555555554095805'), 18)).toBe('255,969.555556'); + }); + + it('should keep small balances readable', () => { + expect(formatBalanceAmount(new BigNumber('123456789'), 18)).toBe('< 0.00000001'); + }); + }); + describe('timeAgo', () => { const mockNow = new Date(2025, 5, 15, 12, 0, 0).getTime(); // 2025-05-15 12:00:00 diff --git a/ui-react/src/components/account/accountTable.tsx b/ui-react/src/components/account/accountTable.tsx index 1631423..9122522 100644 --- a/ui-react/src/components/account/accountTable.tsx +++ b/ui-react/src/components/account/accountTable.tsx @@ -2,7 +2,7 @@ import React, { useMemo } from 'react' import { BareProps } from '@/types/page' import { Table, TableHeader, TableColumn, TableBody, TableRow, TableCell, getKeyValue, Spinner } from '@heroui/react' -import { formatHash, getBalanceAmount, getThemeColor } from '@/utils/text' +import { formatBalanceAmount, formatHash, getThemeColor } from '@/utils/text' import { getExtrinsicListParams, unwrap, useAccounts } from '@/utils/api' import { PAGE_SIZE } from '@/utils/const' import { useData } from '@/context' @@ -67,7 +67,7 @@ const Component: React.FC = ({ children, className, args }) => { {(columnKey) => { if (columnKey === 'balance') { - return {getBalanceAmount(new BigNumber(item.balance), token?.decimals).toFormat()} + return {formatBalanceAmount(new BigNumber(item.balance), token?.decimals)} } else if (columnKey === 'address') { return ( diff --git a/ui-react/src/components/erc20Token/tokenHolderTable.tsx b/ui-react/src/components/erc20Token/tokenHolderTable.tsx index 9f978c8..8ab46bf 100644 --- a/ui-react/src/components/erc20Token/tokenHolderTable.tsx +++ b/ui-react/src/components/erc20Token/tokenHolderTable.tsx @@ -5,7 +5,7 @@ import { Table, TableHeader, TableColumn, TableBody, TableRow, TableCell, getKey import { getPVMTokenHolderListParams, pvmTokenType, unwrap, usePVMTokenHolders } from '@/utils/api' import { PAGE_SIZE } from '@/utils/const' import BigNumber from 'bignumber.js' -import { getBalanceAmount, getThemeColor } from '@/utils/text' +import { formatBalanceAmount, getThemeColor } from '@/utils/text' import { Link } from '../link' import { CursorPagination } from '../cursorPagination' import { env } from 'next-runtime-env' @@ -73,7 +73,7 @@ const Component: React.FC = ({ args, token, children, className }) => { ) } else if (columnKey === 'balance') { - return {getBalanceAmount(new BigNumber(item.balance), token.decimals).toFormat()} + return {formatBalanceAmount(new BigNumber(item.balance), token.decimals)} } return {getKeyValue(item, columnKey)} }} diff --git a/ui-react/src/components/erc20Token/tokenTransferTable.tsx b/ui-react/src/components/erc20Token/tokenTransferTable.tsx index c83119f..01abf81 100644 --- a/ui-react/src/components/erc20Token/tokenTransferTable.tsx +++ b/ui-react/src/components/erc20Token/tokenTransferTable.tsx @@ -10,7 +10,7 @@ import { } from '@/utils/api' import { PAGE_SIZE } from '@/utils/const' import BigNumber from 'bignumber.js' -import { formatHash, getBalanceAmount, getThemeColor, timeAgo } from '@/utils/text' +import { formatBalanceAmount, formatHash, getThemeColor, timeAgo } from '@/utils/text' import { Link } from '../link' import { CursorPagination } from '../cursorPagination' import { env } from 'next-runtime-env' @@ -90,7 +90,7 @@ const Component: React.FC = ({ args, token, children, className }) => { } else if (columnKey === 'value') { return ( - {getBalanceAmount(new BigNumber(item.value), item.decimals).toFormat()} {item.symbol} + {formatBalanceAmount(new BigNumber(item.value), item.decimals)} {item.symbol} ) } else if (columnKey === 'create_at') { diff --git a/ui-react/src/components/pvmAccount/accountTable.tsx b/ui-react/src/components/pvmAccount/accountTable.tsx index ff4fca5..d528aa9 100644 --- a/ui-react/src/components/pvmAccount/accountTable.tsx +++ b/ui-react/src/components/pvmAccount/accountTable.tsx @@ -2,7 +2,7 @@ import React, { useMemo } from 'react' import { BareProps } from '@/types/page' import { Table, TableHeader, TableColumn, TableBody, TableRow, TableCell, getKeyValue, Spinner } from '@heroui/react' -import { getBalanceAmount, getThemeColor } from '@/utils/text' +import { formatBalanceAmount, getThemeColor } from '@/utils/text' import { getExtrinsicListParams, unwrap, usePVMAccounts } from '@/utils/api' import { PAGE_SIZE } from '@/utils/const' import { useData } from '@/context' @@ -66,7 +66,7 @@ const Component: React.FC = ({ children, className, args }) => { {(columnKey) => { if (columnKey === 'balance') { - return {getBalanceAmount(new BigNumber(item.balance), token?.decimals).toFormat()} + return {formatBalanceAmount(new BigNumber(item.balance), token?.decimals)} } else if (columnKey === 'address') { return ( diff --git a/ui-react/src/components/pvmAccount/tokenTable.tsx b/ui-react/src/components/pvmAccount/tokenTable.tsx index 92fbec8..959cfd1 100644 --- a/ui-react/src/components/pvmAccount/tokenTable.tsx +++ b/ui-react/src/components/pvmAccount/tokenTable.tsx @@ -5,7 +5,7 @@ import { Table, Pagination, TableHeader, TableColumn, TableBody, TableRow, Table import { getPVMAccountTokenListParams, getPVMTokenTransferListParams, pvmTokenType, unwrap, usePVMAccountTokens } from '@/utils/api' import { PAGE_SIZE } from '@/utils/const' import BigNumber from 'bignumber.js' -import { formatHash, getBalanceAmount, getThemeColor, timeAgo } from '@/utils/text' +import { formatBalanceAmount, formatHash, getThemeColor, timeAgo } from '@/utils/text' import { Link } from '../link' import { env } from 'next-runtime-env' @@ -55,7 +55,7 @@ const Component: React.FC = ({ args, token, children, className }) => { {(columnKey) => { if (columnKey === 'balance') { - return {getBalanceAmount(new BigNumber(item.balance), item.decimals).toFormat()} + return {formatBalanceAmount(new BigNumber(item.balance), item.decimals)} } else if (columnKey === 'name') { return {`${item.name}(${item.symbol})`} } else if (columnKey === 'category') { diff --git a/ui-react/src/components/transfer/transferTable.tsx b/ui-react/src/components/transfer/transferTable.tsx index 7021e33..56503ba 100644 --- a/ui-react/src/components/transfer/transferTable.tsx +++ b/ui-react/src/components/transfer/transferTable.tsx @@ -2,7 +2,7 @@ import React, { useMemo } from 'react' import { BareProps } from '@/types/page' import { Table, TableHeader, TableColumn, TableBody, TableRow, TableCell, getKeyValue, Spinner } from '@heroui/react' -import { formatHash, getBalanceAmount, getThemeColor, timeAgo } from '@/utils/text' +import { formatBalanceAmount, formatHash, getThemeColor, timeAgo } from '@/utils/text' import { getTransferListParams, unwrap, useTransfers } from '@/utils/api' import { PAGE_SIZE } from '@/utils/const' import { useData } from '@/context' @@ -81,7 +81,7 @@ const Component: React.FC = ({ children, className, args }) => { } else if (columnKey === 'block_timestamp') { return {timeAgo(item.block_timestamp)} } else if (columnKey === 'amount') { - return {getBalanceAmount(new BigNumber(item.amount), token?.decimals).toFormat()} + return {formatBalanceAmount(new BigNumber(item.amount), token?.decimals)} } if (columnKey === 'sender' || columnKey === 'receiver') { const address = columnKey === 'sender' ? item.sender : item.receiver diff --git a/ui-react/src/components/tx/txTable.tsx b/ui-react/src/components/tx/txTable.tsx index ac6e3af..6094525 100644 --- a/ui-react/src/components/tx/txTable.tsx +++ b/ui-react/src/components/tx/txTable.tsx @@ -2,7 +2,7 @@ import React, { useMemo } from 'react' import { BareProps } from '@/types/page' import { Table, TableHeader, TableColumn, TableBody, TableRow, TableCell, getKeyValue, Spinner } from '@heroui/react' -import { formatHash, getBalanceAmount, getThemeColor, timeAgo } from '@/utils/text' +import { formatBalanceAmount, formatHash, getThemeColor, timeAgo } from '@/utils/text' import { getPVMTxListParams, unwrap, usePVMTxs } from '@/utils/api' import { PAGE_SIZE, PVM_DECIMAL } from '@/utils/const' import { useData } from '@/context' @@ -83,7 +83,7 @@ const Component: React.FC = ({ children, className, args }) => { ) } else if (columnKey === 'value') { - return {getBalanceAmount(new BigNumber(item.value), PVM_DECIMAL).toFormat()} + return {formatBalanceAmount(new BigNumber(item.value), PVM_DECIMAL)} } else if (columnKey === 'block_num') { return ( diff --git a/ui-react/src/pages/address/[id].tsx b/ui-react/src/pages/address/[id].tsx index 0307ad7..c33508e 100644 --- a/ui-react/src/pages/address/[id].tsx +++ b/ui-react/src/pages/address/[id].tsx @@ -1,7 +1,7 @@ import React from 'react' import { CardBody, Card, Tabs, Tab } from '@heroui/react' import { useRouter } from 'next/router' -import { getBalanceAmount, getThemeColor } from '@/utils/text' +import { formatBalanceAmount, getThemeColor } from '@/utils/text' import { unwrap, usePVMAccounts } from '@/utils/api' import { useData } from '@/context' import BigNumber from 'bignumber.js' @@ -44,7 +44,7 @@ export default function Page() {
Balance
- {getBalanceAmount(new BigNumber(accountData.balance), token?.decimals).toFormat()} {token?.symbol} + {formatBalanceAmount(new BigNumber(accountData.balance), token?.decimals)} {token?.symbol}
diff --git a/ui-react/src/pages/contract/[id].tsx b/ui-react/src/pages/contract/[id].tsx index 6655b7f..c2e43f6 100644 --- a/ui-react/src/pages/contract/[id].tsx +++ b/ui-react/src/pages/contract/[id].tsx @@ -1,7 +1,7 @@ import React from 'react' import { CardBody, Card, Tabs, Tab, Divider } from '@heroui/react' import { useRouter } from 'next/router' -import { getBalanceAmount, getThemeColor } from '@/utils/text' +import { formatBalanceAmount, getThemeColor } from '@/utils/text' import { unwrap, usePVMAccounts, usePVMContract } from '@/utils/api' import { useData } from '@/context' import { TxTable } from '@/components/tx' @@ -69,7 +69,7 @@ export default function Page() {
Balance
- {getBalanceAmount(new BigNumber(accountData?.balance || 0), token?.decimals).toFormat()} {token?.symbol} + {formatBalanceAmount(new BigNumber(accountData?.balance || 0), token?.decimals)} {token?.symbol}
diff --git a/ui-react/src/pages/sub/account/[id].tsx b/ui-react/src/pages/sub/account/[id].tsx index 17bb35d..5b7743c 100644 --- a/ui-react/src/pages/sub/account/[id].tsx +++ b/ui-react/src/pages/sub/account/[id].tsx @@ -1,7 +1,7 @@ import React, { useMemo } from 'react' import { CardBody, Card, Divider, Tabs, Tab } from '@heroui/react' import { useRouter } from 'next/router' -import { getBalanceAmount, getThemeColor } from '@/utils/text' +import { formatBalanceAmount, getThemeColor } from '@/utils/text' import { unwrap, useAccount } from '@/utils/api' import { useData } from '@/context' import BigNumber from 'bignumber.js' @@ -22,6 +22,7 @@ export default function Page() { }) const accountData = unwrap(data) + const tokenSymbol = token?.symbol ? ` ${token.symbol}` : '' const transferable = useMemo(() => { if (accountData) { @@ -56,27 +57,42 @@ export default function Page() {
Total Balance
-
{getBalanceAmount(new BigNumber(accountData.balance), token?.decimals).toFormat()}
+
+ {formatBalanceAmount(new BigNumber(accountData.balance), token?.decimals)} + {tokenSymbol} +
Transferrable
-
{getBalanceAmount(transferable, token?.decimals).toFormat()}
+
+ {formatBalanceAmount(transferable, token?.decimals)} + {tokenSymbol} +
Locked
-
{getBalanceAmount(new BigNumber(accountData.locked), token?.decimals).toFormat()}
+
+ {formatBalanceAmount(new BigNumber(accountData.locked), token?.decimals)} + {tokenSymbol} +
Vested
-
{getBalanceAmount(new BigNumber(accountData.vested || 0), token?.decimals).toFormat()}
+
+ {formatBalanceAmount(new BigNumber(accountData.vested || 0), token?.decimals)} + {tokenSymbol} +
Reserved
-
{getBalanceAmount(new BigNumber(accountData.reserved), token?.decimals).toFormat()}
+
+ {formatBalanceAmount(new BigNumber(accountData.reserved), token?.decimals)} + {tokenSymbol} +
diff --git a/ui-react/src/pages/token/[id].tsx b/ui-react/src/pages/token/[id].tsx index 1934560..2c6ef27 100644 --- a/ui-react/src/pages/token/[id].tsx +++ b/ui-react/src/pages/token/[id].tsx @@ -1,7 +1,7 @@ import React from 'react' import { CardBody, Card, Tabs, Tab, Divider } from '@heroui/react' import { useRouter } from 'next/router' -import { getBalanceAmount, getThemeColor } from '@/utils/text' +import { formatBalanceAmount, getThemeColor } from '@/utils/text' import { unwrap, usePVMTokens } from '@/utils/api' import { useData } from '@/context' import BigNumber from 'bignumber.js' @@ -53,7 +53,7 @@ export default function Page() {
Token Supply
-
{getBalanceAmount(new BigNumber(tokenData.totalSupply), tokenData.decimals).toFormat()}
+
{formatBalanceAmount(new BigNumber(tokenData.totalSupply), tokenData.decimals)}
diff --git a/ui-react/src/pages/tx/[id].tsx b/ui-react/src/pages/tx/[id].tsx index b203189..365c788 100644 --- a/ui-react/src/pages/tx/[id].tsx +++ b/ui-react/src/pages/tx/[id].tsx @@ -1,7 +1,7 @@ import React, { useMemo } from 'react' import { CardBody, Card, Divider } from '@heroui/react' import { useRouter } from 'next/router' -import { getBalanceAmount, getUTCTime, timeAgo } from '@/utils/text' +import { formatBalanceAmount, getUTCTime, timeAgo } from '@/utils/text' import { unwrap, usePVMTx } from '@/utils/api' import { Container, PageContent } from '@/ui' import { PVM_DECIMAL } from '@/utils/const' @@ -87,7 +87,7 @@ export default function Page() {
Value
-
{getBalanceAmount(new BigNumber(extrinsicData.value), PVM_DECIMAL).toFormat()}
+
{formatBalanceAmount(new BigNumber(extrinsicData.value), PVM_DECIMAL)}
@@ -110,12 +110,12 @@ export default function Page() {
Txn Fee
- {getBalanceAmount( + {formatBalanceAmount( new BigNumber(extrinsicData.gas_used).times( extrinsicData.txn_type === 2 ? extrinsicData.effective_gas_price : extrinsicData.gas_price ), PVM_DECIMAL - ).toFormat()} + )}
diff --git a/ui-react/src/utils/text.ts b/ui-react/src/utils/text.ts index 1fd1ac5..13f4674 100644 --- a/ui-react/src/utils/text.ts +++ b/ui-react/src/utils/text.ts @@ -47,6 +47,36 @@ export function getBalanceAmount(amount: BigNumber, decimals?: number): BigNumbe return new BigNumber(amount).dividedBy(BIG_TEN.pow(decimals || 0)) } +const COMPACT_BALANCE_UNITS = [ + { value: new BigNumber('1000000000000'), label: 'Trillion' }, + { value: new BigNumber('1000000000'), label: 'Billion' }, + { value: new BigNumber('1000000'), label: 'Million' }, +] as const + +export function formatBalanceAmount(amount: BigNumber, decimals?: number): string { + const balance = getBalanceAmount(amount, decimals) + const absBalance = balance.abs() + const compactUnit = COMPACT_BALANCE_UNITS.find((unit) => absBalance.isGreaterThanOrEqualTo(unit.value)) + + if (compactUnit) { + return `${balance.dividedBy(compactUnit.value).decimalPlaces(4, BigNumber.ROUND_HALF_UP).toFormat(4)} ${compactUnit.label}` + } + + if (absBalance.isZero()) { + return '0' + } + + if (absBalance.isLessThan(1)) { + if (absBalance.isLessThan('0.00000001')) { + return balance.isNegative() ? '> -0.00000001' : '< 0.00000001' + } + + return balance.decimalPlaces(8, BigNumber.ROUND_HALF_UP).toFormat() + } + + return balance.decimalPlaces(6, BigNumber.ROUND_HALF_UP).toFormat() +} + export function timeAgo(time: number | string, now = Date.now()) { const second = +time * 1000 const d = new Date(second) From 296a3dc4766c8a110ec62d85a5882db0e20c3d58 Mon Sep 17 00:00:00 2001 From: Steven Date: Tue, 26 May 2026 11:42:12 +0800 Subject: [PATCH 04/11] [feat] RefreshAllAccount --- plugins/balance/balance.go | 15 ++++- plugins/balance/dao/script.go | 108 +++++++++++++++++++++++++++++++++- 2 files changed, 120 insertions(+), 3 deletions(-) diff --git a/plugins/balance/balance.go b/plugins/balance/balance.go index 18386c8..a74a4e8 100644 --- a/plugins/balance/balance.go +++ b/plugins/balance/balance.go @@ -33,9 +33,20 @@ func (a *Balance) Commands() []cli.Command { }, { Name: "RefreshAllAccount", + Flags: []cli.Flag{ + cli.IntFlag{Name: "limit", Value: 10, Usage: "max accounts to refresh in this batch"}, + cli.UintFlag{Name: "start-id", Usage: "refresh accounts with id greater than this value"}, + cli.IntFlag{Name: "sleep-seconds", Value: 3, Usage: "seconds to sleep after each account refresh"}, + cli.StringFlag{Name: "mode", Value: "resume", Usage: "progress mode: resume or reset"}, + }, Action: func(c *cli.Context) error { - dao.RefreshAllAccount(a.storage()) - return nil + return dao.RefreshAllAccount(a.storage(), dao.RefreshAllAccountOptions{ + Limit: c.Int("limit"), + StartID: c.Uint("start-id"), + SleepSeconds: c.Int("sleep-seconds"), + Mode: c.String("mode"), + StartIDSet: c.IsSet("start-id"), + }) }, }, { diff --git a/plugins/balance/dao/script.go b/plugins/balance/dao/script.go index bd7b39d..5c18299 100644 --- a/plugins/balance/dao/script.go +++ b/plugins/balance/dao/script.go @@ -2,6 +2,7 @@ package dao import ( "context" + "fmt" "github.com/itering/subscan-plugin/storage" "github.com/itering/subscan/model" bModel "github.com/itering/subscan/plugins/balance/model" @@ -11,7 +12,10 @@ import ( "github.com/panjf2000/ants/v2" "gorm.io/gorm" "log" + "strconv" + "strings" "sync" + "time" ) func InitAccount(sg *Storage) { @@ -100,8 +104,110 @@ func readAllVesting(ctx context.Context) map[string][]bModel.VestingInfo { return result } -func RefreshAllAccount(_ *Storage) { +type RefreshAllAccountOptions struct { + Limit int + StartID uint + SleepSeconds int + Mode string + StartIDSet bool +} + +type refreshProgressCache interface { + GetCacheString(context.Context, string) string + SetCache(context.Context, string, interface{}, int) error +} +func RefreshAllAccount(sg *Storage, options ...RefreshAllAccountOptions) error { + opt := RefreshAllAccountOptions{ + Limit: 10, + SleepSeconds: 3, + } + if len(options) > 0 { + opt = options[0] + } + if opt.Limit <= 0 { + opt.Limit = 10 + } + if opt.SleepSeconds < 0 { + opt.SleepSeconds = 3 + } + opt.Mode = strings.ToLower(strings.TrimSpace(opt.Mode)) + if opt.Mode == "" { + opt.Mode = "resume" + } + + ctx := context.Background() + progressKey := model.RedisKeyPrefix() + "balance_refresh_all_account_progress" + cache, cacheOK := sg.Pool.(refreshProgressCache) + if opt.Mode == "reset" { + opt.StartID = 0 + if cacheOK { + _ = cache.SetCache(ctx, progressKey, 0, -1) + } + fmt.Printf("RefreshAllAccount: reset progress key=%s start_id=0\n", progressKey) + } else if opt.Mode == "resume" && !opt.StartIDSet && cacheOK { + if saved := cache.GetCacheString(ctx, progressKey); saved != "" { + if savedID, err := strconv.ParseUint(saved, 10, 64); err == nil { + opt.StartID = uint(savedID) + fmt.Printf("RefreshAllAccount: resume from progress key=%s start_id=%d\n", progressKey, opt.StartID) + } + } + } else if opt.Mode != "resume" && opt.Mode != "reset" { + return fmt.Errorf("unsupported mode %q, use resume or reset", opt.Mode) + } + if !cacheOK { + fmt.Printf("RefreshAllAccount: progress cache unavailable, using start_id=%d\n", opt.StartID) + } + + db := sg.Dao.GetDbInstance().(*gorm.DB) + var remaining int64 + if err := db.WithContext(ctx).Model(&bModel.Account{}).Where("id > ?", opt.StartID).Count(&remaining).Error; err != nil { + return err + } + var accounts []bModel.Account + if err := db.WithContext(ctx). + Select("id", "address"). + Where("id > ?", opt.StartID). + Order("id asc"). + Limit(opt.Limit). + Find(&accounts).Error; err != nil { + return err + } + if len(accounts) == 0 { + fmt.Printf("RefreshAllAccount: no accounts found after id %d\n", opt.StartID) + return nil + } + + fmt.Printf("RefreshAllAccount: mode=%s start_id=%d limit=%d sleep_seconds=%d remaining_after_start=%d batch_size=%d progress_key=%s\n", + opt.Mode, opt.StartID, opt.Limit, opt.SleepSeconds, remaining, len(accounts), progressKey) + + sleepDuration := time.Duration(opt.SleepSeconds) * time.Second + var success, failed int + for index, account := range accounts { + if err := RefreshAccount(ctx, sg, account.Address); err != nil { + failed++ + fmt.Printf("RefreshAllAccount: progress=%d/%d id=%d address=%s status=failed success=%d failed=%d err=%v\n", + index+1, len(accounts), account.ID, account.Address, success, failed, err) + } else { + success++ + if cacheOK { + _ = cache.SetCache(ctx, progressKey, int(account.ID), -1) + } + fmt.Printf("RefreshAllAccount: progress=%d/%d id=%d address=%s status=refreshed success=%d failed=%d next_start_id=%d\n", + index+1, len(accounts), account.ID, account.Address, success, failed, account.ID) + } + if sleepDuration > 0 { + fmt.Printf("RefreshAllAccount: sleeping %s before next account\n", sleepDuration) + time.Sleep(sleepDuration) + } + } + + lastID := accounts[len(accounts)-1].ID + if cacheOK { + _ = cache.SetCache(ctx, progressKey, int(lastID), -1) + } + fmt.Printf("RefreshAllAccount: done success=%d failed=%d last_id=%d next_start_id=%d progress_key=%s\n", success, failed, lastID, lastID, progressKey) + return nil } func InitTransfer(sg *Storage) { From c1c6bf697d22fe363c0cbe3f791fa63690d4beab Mon Sep 17 00:00:00 2001 From: "crossagent-production-app[bot]" <283591059+crossagent-production-app[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 15:12:14 +0800 Subject: [PATCH 05/11] Fix AgentKeys live ABI audit paths (#43) Merge CrossAgent delivery for issue #42 after CI and crossagent/evidence passed.\n\nEvidence: https://github.com/litentry/subscan-essentials/pull/43#issuecomment-4544756151 --- internal/agentkeys/audit.go | 78 ++++++++++++-- internal/agentkeys/audit_test.go | 171 ++++++++++++++++++++++++++++++ internal/server/http/agentkeys.go | 83 ++++++++++----- 3 files changed, 297 insertions(+), 35 deletions(-) diff --git a/internal/agentkeys/audit.go b/internal/agentkeys/audit.go index 794a006..3e80642 100644 --- a/internal/agentkeys/audit.go +++ b/internal/agentkeys/audit.go @@ -25,16 +25,18 @@ const ( HeimaChainID = 212013 CredentialAuditContractAddress = "0x63c4545ac01c77cc74044f25b8edea3880224577" - AuditAppendedV2Signature = "AuditAppendedV2(bytes32,bytes32,uint8,bytes32)" - AuditAppendedCurrentSignature = "AuditAppended(bytes32,bytes32,bytes32,uint8,uint256,bytes32)" - AuditRootAppendedV2Signature = "AuditRootAppendedV2(bytes32,bytes32,bytes32,uint64)" + AuditAppendedV2Signature = "AuditAppendedV2(bytes32,bytes32,uint8,bytes32)" + AuditAppendedCurrentSignature = "AuditAppended(bytes32,bytes32,bytes32,uint8,uint256,bytes32)" + AuditRootAppendedV2Signature = "AuditRootAppendedV2(bytes32,bytes32,bytes32,uint64)" + AuditRootAppendedCurrentSignature = "AuditRootAppended(bytes32,bytes32,uint256,uint64)" ) var ( - AuditAppendedV2Topic = crypto.Keccak256Hash([]byte(AuditAppendedV2Signature)).Hex() - AuditAppendedCurrentTopic = crypto.Keccak256Hash([]byte(AuditAppendedCurrentSignature)).Hex() - AuditRootAppendedV2Topic = crypto.Keccak256Hash([]byte(AuditRootAppendedV2Signature)).Hex() - ErrEnvelopeNotFound = errors.New("agentkeys audit envelope not found") + AuditAppendedV2Topic = crypto.Keccak256Hash([]byte(AuditAppendedV2Signature)).Hex() + AuditAppendedCurrentTopic = crypto.Keccak256Hash([]byte(AuditAppendedCurrentSignature)).Hex() + AuditRootAppendedV2Topic = crypto.Keccak256Hash([]byte(AuditRootAppendedV2Signature)).Hex() + AuditRootAppendedCurrentTopic = crypto.Keccak256Hash([]byte(AuditRootAppendedCurrentSignature)).Hex() + ErrEnvelopeNotFound = errors.New("agentkeys audit envelope not found") ) type Envelope struct { @@ -72,9 +74,12 @@ type AuditAppendedV2Event struct { } type AuditRootAppendedV2Event struct { + EventName string `json:"event_name"` + EventTopic string `json:"event_topic"` OperatorOmni string `json:"operator_omni"` MerkleRoot string `json:"merkle_root"` - OpKindBitmapU256 string `json:"op_kind_bitmap_u256"` + OpKindBitmapU256 string `json:"op_kind_bitmap_u256,omitempty"` + RootIndex string `json:"root_index,omitempty"` EntryCount uint64 `json:"entry_count"` } @@ -121,7 +126,8 @@ type AuditRootRows struct { ContractAddress string `json:"contract_address"` MerkleRoot string `json:"merkle_root"` OperatorOmni string `json:"operator_omni"` - OpKindBitmapU256 string `json:"op_kind_bitmap_u256"` + OpKindBitmapU256 string `json:"op_kind_bitmap_u256,omitempty"` + RootIndex string `json:"root_index,omitempty"` EntryCount uint64 `json:"entry_count"` Block uint64 `json:"block"` BlockHash string `json:"block_hash"` @@ -470,7 +476,7 @@ func DecodeTypedAuditRowsBestEffort(ctx context.Context, logs []EVMLogRecord, wo } func DecodeAuditRootRows(ctx context.Context, rootLog EVMLogRecord, leafLogs []EVMLogRecord, workerBaseURL string, cache *EnvelopeCache) (*AuditRootRows, error) { - event, err := DecodeAuditRootAppendedV2Log(rootLog.Topics, rootLog.Data) + event, err := DecodeAuditRootAppendedLog(rootLog.Topics, rootLog.Data) if err != nil { return nil, err } @@ -483,7 +489,7 @@ func DecodeAuditRootRows(ctx context.Context, rootLog EVMLogRecord, leafLogs []E return nil, fmt.Errorf("root logIndex: %w", err) } - rows, err := DecodeTypedAuditRows(ctx, leafLogs, workerBaseURL, cache) + rows, err := DecodeTypedAuditRowsBestEffort(ctx, leafLogs, workerBaseURL, cache) if err != nil { return nil, err } @@ -504,6 +510,7 @@ func DecodeAuditRootRows(ctx context.Context, rootLog EVMLogRecord, leafLogs []E MerkleRoot: event.MerkleRoot, OperatorOmni: event.OperatorOmni, OpKindBitmapU256: event.OpKindBitmapU256, + RootIndex: event.RootIndex, EntryCount: event.EntryCount, Block: block, BlockHash: normalizeHexHash(rootLog.BlockHash), @@ -518,6 +525,10 @@ func PaddedOpKindTopic(opKind uint8) string { return "0x" + strings.Repeat("0", 62) + fmt.Sprintf("%02x", opKind) } +func CurrentAuditOpKindDataPrefix(opKind uint8) string { + return fmt.Sprintf("%064x", opKind) +} + func OpKindTopicsFromBitmap(bitmap string) ([]string, error) { bytes, err := hex.DecodeString(strings.TrimPrefix(strings.ToLower(bitmap), "0x")) if err != nil { @@ -631,6 +642,8 @@ func DecodeAuditRootAppendedV2Log(topics []string, data string) (*AuditRootAppen return nil, fmt.Errorf("entry_count: %w", err) } return &AuditRootAppendedV2Event{ + EventName: "AuditRootAppendedV2", + EventTopic: AuditRootAppendedV2Topic, OperatorOmni: normalizeBytes32Topic(topics[1]), MerkleRoot: normalizeBytes32Topic(topics[2]), OpKindBitmapU256: bitmap, @@ -638,6 +651,49 @@ func DecodeAuditRootAppendedV2Log(topics []string, data string) (*AuditRootAppen }, nil } +func DecodeAuditRootAppendedLog(topics []string, data string) (*AuditRootAppendedV2Event, error) { + if len(topics) == 0 { + return nil, fmt.Errorf("audit root event requires topic0") + } + switch { + case strings.EqualFold(topics[0], AuditRootAppendedV2Topic): + return DecodeAuditRootAppendedV2Log(topics, data) + case strings.EqualFold(topics[0], AuditRootAppendedCurrentTopic): + return DecodeAuditRootAppendedCurrentLog(topics, data) + default: + return nil, fmt.Errorf("unexpected audit root event topic0 %s", topics[0]) + } +} + +func DecodeAuditRootAppendedCurrentLog(topics []string, data string) (*AuditRootAppendedV2Event, error) { + if len(topics) != 3 { + return nil, fmt.Errorf("AuditRootAppended requires 3 topics") + } + if !strings.EqualFold(topics[0], AuditRootAppendedCurrentTopic) { + return nil, fmt.Errorf("unexpected AuditRootAppended topic0 %s", topics[0]) + } + rootIndex, err := abiUint256Decimal(data, 0) + if err != nil { + return nil, fmt.Errorf("root_index: %w", err) + } + countHex, err := abiWord(data, 1) + if err != nil { + return nil, fmt.Errorf("entry_count: %w", err) + } + count, err := strconv.ParseUint(countHex[48:], 16, 64) + if err != nil { + return nil, fmt.Errorf("entry_count: %w", err) + } + return &AuditRootAppendedV2Event{ + EventName: "AuditRootAppended", + EventTopic: AuditRootAppendedCurrentTopic, + OperatorOmni: normalizeBytes32Topic(topics[1]), + MerkleRoot: normalizeBytes32Topic(topics[2]), + RootIndex: rootIndex, + EntryCount: count, + }, nil +} + func FetchEnvelope(ctx context.Context, workerBaseURL string, hash string) ([]byte, error) { base, err := url.Parse(workerBaseURL) if err != nil { diff --git a/internal/agentkeys/audit_test.go b/internal/agentkeys/audit_test.go index dfc1fd6..7c36663 100644 --- a/internal/agentkeys/audit_test.go +++ b/internal/agentkeys/audit_test.go @@ -9,6 +9,7 @@ import ( "net/http" "net/http/httptest" "os" + "sort" "strings" "testing" @@ -423,6 +424,43 @@ func TestDecodeTypedAuditRowsAndRootLeaves(t *testing.T) { assert.Equal(t, "DeviceAdd", rootRows.Rows[1].OpKindName) } +func TestDecodeAuditRootRowsCurrentKeepsChainOnlyLeavesWhenWorkerMissing(t *testing.T) { + operator := "0x941cb1c3260518bbf40eac7d02663517fc7cff304d9b03e80d2cc54126c6bef2" + actor := "0x82a0609ae28527453e7c7654ec11d57f1b66a442e39a2ec5e3b3f76178c87268" + rootHash := "0x32301a0bd7c9c1d064f0d3891c78ad00a6d9fa758ebb14a1a0ff64eb4f4ca3aa" + firstHash := "0xdb927ad4467c02867819a1379c7c0b9a35103452c789badeae6e531b5d2f8e1c" + secondHash := "0x3d67f9734a38829d9e2289cd9551caa7f50ba66bd521981fb8504be4ab23a223" + rootLog := auditRootCurrentLog(operator, rootHash, "0x"+strings.Repeat("0", 62)+"1c", 2, 9634690, 0) + leafLogs := []EVMLogRecord{ + auditAppendedCurrentLog(operator, actor, 0, secondHash, 1, 9625271, 0), + auditAppendedCurrentLog(operator, actor, 0, firstHash, 0, 9625257, 0), + } + + srv := httptest.NewServer(http.NotFoundHandler()) + defer srv.Close() + + rootRows, err := DecodeAuditRootRows(context.Background(), rootLog, leafLogs, srv.URL, NewEnvelopeCache()) + require.NoError(t, err) + assert.Equal(t, rootHash, rootRows.MerkleRoot) + assert.Equal(t, operator, rootRows.OperatorOmni) + assert.Empty(t, rootRows.OpKindBitmapU256) + assert.Equal(t, "28", rootRows.RootIndex) + assert.Equal(t, uint64(2), rootRows.EntryCount) + assert.Equal(t, uint64(9634690), rootRows.Block) + assert.Equal(t, []string{firstHash, secondHash}, rootRows.Leaves) + require.Len(t, rootRows.Rows, 2) + assert.Equal(t, "0", rootRows.Rows[0].CurrentSequence) + assert.Equal(t, "1", rootRows.Rows[1].CurrentSequence) + for _, row := range rootRows.Rows { + assert.Equal(t, "AuditAppended", row.EventName) + assert.Equal(t, uint8(0), row.OpKind) + assert.Equal(t, "CredStore", row.OpKindName) + assert.False(t, row.EnvelopeAvailable) + assert.False(t, row.HashVerified) + require.NotNil(t, row.EnvelopeFetchError) + } +} + func TestDecodeTypedAuditRowsBestEffortKeepsLiveChainRowsWhenWorkerMissing(t *testing.T) { operator := "0x" + strings.Repeat("94", 32) actor := "0x" + strings.Repeat("82", 32) @@ -444,6 +482,24 @@ func TestDecodeTypedAuditRowsBestEffortKeepsLiveChainRowsWhenWorkerMissing(t *te assert.Contains(t, *rows[0].EnvelopeFetchError, "agentkeys audit envelope not found") } +func TestCurrentAuditOpKindDataPrefixMatchesStoredReceiptData(t *testing.T) { + prefix := CurrentAuditOpKindDataPrefix(0) + + assert.Equal(t, strings.Repeat("0", 64), prefix) + assert.NotContains(t, prefix, "0x") + + liveStoredData := strings.TrimPrefix( + "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000db927ad4467c02867819a1379c7c0b9a35103452c789badeae6e531b5d2f8e1c", + "0x", + ) + assert.True(t, strings.HasPrefix(liveStoredData, prefix)) +} + +func TestCurrentAuditOpKindDataPrefixSupportsUint8Boundary(t *testing.T) { + assert.Equal(t, strings.Repeat("0", 62)+"ff", CurrentAuditOpKindDataPrefix(255)) + assert.Equal(t, fmt.Sprintf("%064x", 21), CurrentAuditOpKindDataPrefix(21)) +} + func TestDecodeLiveHeimaCurrentAuditFixture(t *testing.T) { body, err := os.ReadFile("../../tests/fixtures/agentkeys/heima-mainnet-current-auditappended.jsonl") require.NoError(t, err) @@ -482,6 +538,57 @@ func TestDecodeLiveHeimaCurrentAuditFixture(t *testing.T) { } } +func TestDecodeLiveHeimaCurrentAuditFixtureFilteringAndPagination(t *testing.T) { + rows := decodeLiveHeimaCurrentRows(t) + require.Len(t, rows, 13) + + opKindRows := rows[:0] + for _, row := range rows { + if row.OpKind == 0 { + opKindRows = append(opKindRows, row) + } + } + require.Len(t, opKindRows, len(rows)) + + sort.SliceStable(opKindRows, func(i, j int) bool { + if opKindRows[i].Block == opKindRows[j].Block { + return opKindRows[i].LogIndex > opKindRows[j].LogIndex + } + return opKindRows[i].Block > opKindRows[j].Block + }) + + firstPage := opKindRows[:5] + secondPage := make([]TypedAuditRow, 0, 5) + cursorBlock := firstPage[len(firstPage)-1].Block + cursorLogIndex := firstPage[len(firstPage)-1].LogIndex + for _, row := range opKindRows { + if row.Block < cursorBlock || (row.Block == cursorBlock && row.LogIndex < cursorLogIndex) { + secondPage = append(secondPage, row) + if len(secondPage) == 5 { + break + } + } + } + + require.Len(t, firstPage, 5) + require.Len(t, secondPage, 5) + assert.Equal(t, uint64(9632387), firstPage[0].Block) + assert.Equal(t, "12", firstPage[0].CurrentSequence) + assert.Equal(t, uint64(9631511), firstPage[4].Block) + assert.Equal(t, "8", firstPage[4].CurrentSequence) + assert.Equal(t, uint64(9631477), secondPage[0].Block) + assert.Equal(t, "7", secondPage[0].CurrentSequence) + combined := append([]TypedAuditRow{}, firstPage...) + combined = append(combined, secondPage...) + for _, row := range combined { + assert.Equal(t, "AuditAppended", row.EventName) + assert.Equal(t, uint8(0), row.OpKind) + assert.Equal(t, "CredStore", row.OpKindName) + assert.False(t, row.EnvelopeAvailable) + require.NotNil(t, row.EnvelopeFetchError) + } +} + func TestDecodeEnvelopeRejectsVersionAndNonCanonicalMap(t *testing.T) { envelope := map[string]interface{}{ "version": uint8(2), @@ -546,8 +653,17 @@ func TestDecodeAuditEventLogs(t *testing.T) { rootData := "0x" + strings.Repeat("dd", 32) + strings.Repeat("0", 63) + "7" root, err := DecodeAuditRootAppendedV2Log([]string{AuditRootAppendedV2Topic, operator, envelopeHash}, rootData) require.NoError(t, err) + assert.Equal(t, "AuditRootAppendedV2", root.EventName) assert.Equal(t, "0x"+strings.Repeat("dd", 32), root.OpKindBitmapU256) assert.Equal(t, uint64(7), root.EntryCount) + + currentRootData := "0x" + strings.Repeat("0", 62) + "1c" + strings.Repeat("0", 63) + "2" + currentRoot, err := DecodeAuditRootAppendedCurrentLog([]string{AuditRootAppendedCurrentTopic, operator, envelopeHash}, currentRootData) + require.NoError(t, err) + assert.Equal(t, "AuditRootAppended", currentRoot.EventName) + assert.Empty(t, currentRoot.OpKindBitmapU256) + assert.Equal(t, "28", currentRoot.RootIndex) + assert.Equal(t, uint64(2), currentRoot.EntryCount) } func TestFetchEnvelopeAndDecodeAcceptsHashWithoutPrefix(t *testing.T) { @@ -676,6 +792,61 @@ func auditRootLog(operator, merkleRoot string, opKinds []uint8, entryCount uint6 } } +func auditRootCurrentLog(operator, merkleRoot string, rootIndex string, entryCount uint64, block uint64, logIndex uint64) EVMLogRecord { + return EVMLogRecord{ + Address: CredentialAuditContractAddress, + Topics: []string{AuditRootAppendedCurrentTopic, operator, merkleRoot}, + Data: "0x" + strings.TrimPrefix(rootIndex, "0x") + fmt.Sprintf("%064x", entryCount), + BlockNumber: fmt.Sprintf("0x%x", block), + BlockHash: "0x" + strings.Repeat("77", 32), + Timestamp: "0x65f00000", + LogIndex: fmt.Sprintf("0x%x", logIndex), + TransactionHash: "0x" + strings.Repeat("88", 32), + TransactionIndex: "0x0", + } +} + +func decodeLiveHeimaCurrentRows(t *testing.T) []TypedAuditRow { + t.Helper() + + body, err := os.ReadFile("../../tests/fixtures/agentkeys/heima-mainnet-current-auditappended.jsonl") + require.NoError(t, err) + + srv := httptest.NewServer(http.NotFoundHandler()) + defer srv.Close() + + logs := make([]EVMLogRecord, 0) + for _, line := range strings.Split(strings.TrimSpace(string(body)), "\n") { + var row struct { + ContractAddress string `json:"contract_address"` + RawTopics []string `json:"raw_topics"` + RawData string `json:"raw_data"` + BlockNumber uint64 `json:"block_number"` + BlockHash string `json:"block_hash"` + Timestamp uint64 `json:"timestamp"` + TxHash string `json:"txhash"` + TransactionIndex uint64 `json:"transaction_index"` + LogIndex uint64 `json:"log_index"` + } + require.NoError(t, json.Unmarshal([]byte(line), &row)) + logs = append(logs, EVMLogRecord{ + Address: row.ContractAddress, + Topics: row.RawTopics, + Data: row.RawData, + BlockNumber: fmt.Sprintf("0x%x", row.BlockNumber), + BlockHash: row.BlockHash, + Timestamp: fmt.Sprintf("0x%x", row.Timestamp), + LogIndex: fmt.Sprintf("0x%x", row.LogIndex), + TransactionHash: row.TxHash, + TransactionIndex: fmt.Sprintf("0x%x", row.TransactionIndex), + }) + } + + rows, err := DecodeTypedAuditRowsBestEffort(context.Background(), logs, srv.URL, NewEnvelopeCache()) + require.NoError(t, err) + return rows +} + func mustHexBytes(t *testing.T, value string) []byte { t.Helper() b, err := hex.DecodeString(strings.TrimPrefix(value, "0x")) diff --git a/internal/server/http/agentkeys.go b/internal/server/http/agentkeys.go index 511afba..e40839a 100644 --- a/internal/server/http/agentkeys.go +++ b/internal/server/http/agentkeys.go @@ -86,10 +86,20 @@ func agentkeysAuditRowsHandle(c *gin.Context) { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) return } - if query.opKind != nil { + rows, nextCursor := agentkeysAuditRowsPage(rows, sortDir, limit, query.opKind) + c.JSON(http.StatusOK, agentkeys.AuditRowsPage{ + ChainID: agentkeys.HeimaChainID, + ContractAddress: agentkeysAuditContractAddress(), + Events: rows, + NextCursor: nextCursor, + }) +} + +func agentkeysAuditRowsPage(rows []agentkeys.TypedAuditRow, sortDir string, limit int, opKind *uint8) ([]agentkeys.TypedAuditRow, *string) { + if opKind != nil { filtered := rows[:0] for _, row := range rows { - if row.OpKind == *query.opKind { + if row.OpKind == *opKind { filtered = append(filtered, row) } } @@ -116,28 +126,19 @@ func agentkeysAuditRowsHandle(c *gin.Context) { cursor := encodeAgentKeysCursor(agentkeysAuditCursor{Block: rows[len(rows)-1].Block, LogIndex: rows[len(rows)-1].LogIndex}) nextCursor = &cursor } - c.JSON(http.StatusOK, agentkeys.AuditRowsPage{ - ChainID: agentkeys.HeimaChainID, - ContractAddress: agentkeysAuditContractAddress(), - Events: rows, - NextCursor: nextCursor, - }) + return rows, nextCursor } func agentkeysAuditRootHandle(c *gin.Context) { root := normalizeAgentKeysBytes32(c.Param("merkle_root")) - rootLogs := agentkeysEvmAPI.API_GetLogsForAgentKeys(c.Request.Context(), "block_num desc, `index` desc", 1, - model.Where("address = ?", agentkeysAuditContractAddress()), - model.Where("method_hash = ?", agentkeys.AuditRootAppendedV2Topic), - model.Where("topic2 = ?", root), - ) + rootLogs := agentkeysAuditRootLogs(c, root) if len(rootLogs) == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "not_found"}) return } rootRecord := toAgentKeysLogs(rootLogs)[0] - rootEvent, err := agentkeys.DecodeAuditRootAppendedV2Log(rootRecord.Topics, rootRecord.Data) + rootEvent, err := agentkeys.DecodeAuditRootAppendedLog(rootRecord.Topics, rootRecord.Data) if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) return @@ -152,21 +153,29 @@ func agentkeysAuditRootHandle(c *gin.Context) { c.JSON(http.StatusBadGateway, gin.H{"error": "root logIndex: " + err.Error()}) return } - opKindTopics, err := agentkeys.OpKindTopicsFromBitmap(rootEvent.OpKindBitmapU256) - if err != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) - return - } - leafLogs := []evmdao.EtherscanLogsRes{} - if rootEvent.EntryCount > 0 && len(opKindTopics) > 0 { + if rootEvent.EntryCount > 0 && strings.EqualFold(rootEvent.EventTopic, agentkeys.AuditRootAppendedCurrentTopic) { leafLogs = agentkeysEvmAPI.API_GetLogsForAgentKeys(c.Request.Context(), "block_num desc, `index` desc", int(rootEvent.EntryCount), model.Where("address = ?", agentkeysAuditContractAddress()), - model.Where("method_hash = ?", agentkeys.AuditAppendedV2Topic), + model.Where("method_hash = ?", agentkeys.AuditAppendedCurrentTopic), model.Where("topic1 = ?", rootEvent.OperatorOmni), - model.Where("topic3 in ?", opKindTopics), model.Where("(block_num < ? OR (block_num = ? AND `index` < ?))", rootBlock, rootBlock, rootLogIndex), ) + } else if rootEvent.EntryCount > 0 { + opKindTopics, err := agentkeys.OpKindTopicsFromBitmap(rootEvent.OpKindBitmapU256) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + if len(opKindTopics) > 0 { + leafLogs = agentkeysEvmAPI.API_GetLogsForAgentKeys(c.Request.Context(), "block_num desc, `index` desc", int(rootEvent.EntryCount), + model.Where("address = ?", agentkeysAuditContractAddress()), + model.Where("method_hash = ?", agentkeys.AuditAppendedV2Topic), + model.Where("topic1 = ?", rootEvent.OperatorOmni), + model.Where("topic3 in ?", opKindTopics), + model.Where("(block_num < ? OR (block_num = ? AND `index` < ?))", rootBlock, rootBlock, rootLogIndex), + ) + } } rows, err := agentkeys.DecodeAuditRootRows(c.Request.Context(), rootRecord, toAgentKeysLogs(leafLogs), agentkeysAuditWorkerURL(), agentkeysEnvelopeCache) @@ -177,6 +186,31 @@ func agentkeysAuditRootHandle(c *gin.Context) { c.JSON(http.StatusOK, rows) } +func agentkeysAuditRootLogs(c *gin.Context, root string) []evmdao.EtherscanLogsRes { + opts := []model.Option{ + model.Where("address = ?", agentkeysAuditContractAddress()), + model.Where("topic2 = ?", root), + } + logs := agentkeysEvmAPI.API_GetLogsForAgentKeys(c.Request.Context(), "block_num desc, `index` desc", 1, + appendAgentKeysAuditOpts(opts, model.Where("method_hash = ?", agentkeys.AuditRootAppendedCurrentTopic))...) + logs = append(logs, agentkeysEvmAPI.API_GetLogsForAgentKeys(c.Request.Context(), "block_num desc, `index` desc", 1, + appendAgentKeysAuditOpts(opts, model.Where("method_hash = ?", agentkeys.AuditRootAppendedV2Topic))...)...) + sort.SliceStable(logs, func(i, j int) bool { + leftBlock, _ := parseAgentKeysUint(logs[i].BlockNumber) + rightBlock, _ := parseAgentKeysUint(logs[j].BlockNumber) + if leftBlock == rightBlock { + leftIndex, _ := parseAgentKeysUint(logs[i].LogIndex) + rightIndex, _ := parseAgentKeysUint(logs[j].LogIndex) + return leftIndex > rightIndex + } + return leftBlock > rightBlock + }) + if len(logs) > 1 { + logs = logs[:1] + } + return logs +} + func agentkeysAuditWorkerURL() string { workerURL := os.Getenv("AGENTKEYS_AUDIT_WORKER_URL") if workerURL == "" { @@ -248,7 +282,8 @@ func agentkeysAuditLogs(c *gin.Context, order string, limit int, query agentkeys } currentOpts := appendAgentKeysAuditOpts(query.opts, model.Where("method_hash = ?", agentkeys.AuditAppendedCurrentTopic)) if query.opKind != nil { - currentOpts = append(currentOpts, model.Where("data like ?", "0x"+fmt.Sprintf("%064x", *query.opKind)+"%")) + prefix := agentkeys.CurrentAuditOpKindDataPrefix(*query.opKind) + currentOpts = append(currentOpts, model.Where("(data like ? or data like ?)", prefix+"%", "0x"+prefix+"%")) } logs := agentkeysEvmAPI.API_GetLogsForAgentKeys(c.Request.Context(), order, limit, v2Opts...) logs = append(logs, agentkeysEvmAPI.API_GetLogsForAgentKeys(c.Request.Context(), order, limit, currentOpts...)...) From cc97c573ee2a9e6d2e2e2af848a2a3904234f993 Mon Sep 17 00:00:00 2001 From: Xin Date: Wed, 27 May 2026 15:17:59 +0800 Subject: [PATCH 06/11] Fix test explorer deploy checkout cleanup (#44) Fix the test explorer deploy script so a CI-owned checkout with local changes is cleaned before switching to origin/crossagent.\n\nValidation: bash -n scripts/deploy-test-explorer-api.sh\n\nRelated: #42, #43 --- scripts/deploy-test-explorer-api.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/deploy-test-explorer-api.sh b/scripts/deploy-test-explorer-api.sh index c092db1..ae43380 100755 --- a/scripts/deploy-test-explorer-api.sh +++ b/scripts/deploy-test-explorer-api.sh @@ -39,6 +39,8 @@ ensure_source() { fi git -C "$SRC_DIR" fetch --prune origin "$BRANCH" + git -C "$SRC_DIR" reset --hard + git -C "$SRC_DIR" clean -fdx -e .ci-deploy-owned git -C "$SRC_DIR" checkout -B "$BRANCH" "origin/$BRANCH" git -C "$SRC_DIR" reset --hard "origin/$BRANCH" git -C "$SRC_DIR" clean -fdx -e .ci-deploy-owned From e334b5f707bab3b5328f4b07b3ebd260613cfe05 Mon Sep 17 00:00:00 2001 From: "crossagent-production-app[bot]" <283591059+crossagent-production-app[bot]@users.noreply.github.com> Date: Thu, 28 May 2026 01:07:21 +0800 Subject: [PATCH 07/11] Auto-detect search address type (#38) * Auto detect search input type * Auto-detect navbar search type --------- Co-authored-by: CrossAgent --- internal/server/http/scan.go | 65 +++++++-- plugins/evm/dao/contract.go | 5 + .../src/__tests__/components/Navbar.test.tsx | 24 +++- ui-react/src/components/navbar/navbar.tsx | 127 +++++++++++++----- ui-react/src/utils/api.ts | 8 ++ util/address/address.go | 22 +++ util/address/address_test.go | 9 ++ 7 files changed, 212 insertions(+), 48 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/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, 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 b2b290a5c8f7e7ac86b0dacacb3d3ccca3d8a592 Mon Sep 17 00:00:00 2001 From: "crossagent-production-app[bot]" <283591059+crossagent-production-app[bot]@users.noreply.github.com> Date: Thu, 28 May 2026 01:36:49 +0800 Subject: [PATCH 08/11] Add verified source contract lookup (#25) Co-authored-by: CrossAgent --- plugins/evm/dao/api.go | 7 +- plugins/evm/dao/api_cursor_test.go | 35 ++++++++ plugins/evm/http/api_test.go | 2 +- plugins/evm/http/http.go | 9 +- .../src/components/contract/contractTable.tsx | 89 ++++++++++--------- ui-react/src/components/contract/info.tsx | 2 +- ui-react/src/pages/contract/[id].tsx | 7 +- ui-react/src/utils/api.ts | 1 + 8 files changed, 102 insertions(+), 50 deletions(-) diff --git a/plugins/evm/dao/api.go b/plugins/evm/dao/api.go index 703f03f..379b32b 100644 --- a/plugins/evm/dao/api.go +++ b/plugins/evm/dao/api.go @@ -26,7 +26,7 @@ type ISrv interface { BlockByHash(ctx context.Context, hash string) *EvmBlock TransactionsCursor(ctx context.Context, limit int, before, after *uint, opts ...model.Option) ([]TransactionSampleJson, map[string]interface{}) AccountsCursor(ctx context.Context, address string, limit int, before, after *string) ([]AccountsJson, map[string]interface{}) - ContractsCursor(ctx context.Context, limit int, before, after *string) ([]ContractsJson, map[string]interface{}) + ContractsCursor(ctx context.Context, limit int, before, after *string, verifiedSourceOnly bool) ([]ContractsJson, map[string]interface{}) AccountTokens(ctx context.Context, address, category string) []AccountTokenJson CollectiblesCursor(ctx context.Context, address string, contract string, limit int, before, after *string) ([]Erc721Holders, map[string]interface{}) @@ -546,10 +546,13 @@ func (c ContractsJson) Cursor() string { return util.Base64Encode(fmt.Sprintf("%d_%s", c.TransactionCount, c.Address)) } -func (a *ApiSrv) ContractsCursor(ctx context.Context, limit int, before, after *string) ([]ContractsJson, map[string]interface{}) { +func (a *ApiSrv) ContractsCursor(ctx context.Context, limit int, before, after *string, verifiedSourceOnly bool) ([]ContractsJson, map[string]interface{}) { var list []ContractsJson fetch := limit + 1 q := sg.db.WithContext(ctx).Model(&Contract{}).Select("contract_name,address,transaction_count,verify_status") + if verifiedSourceOnly { + q = q.Where("verify_status = ? AND source_code IS NOT NULL AND source_code <> ?", "verified", "") + } if cursor := cursorDecode(after); len(cursor) == 2 { q = q.Where("(transaction_count,address) < (?,?)", cursor[0], cursor[1]).Order("transaction_count desc").Order("address desc") } else if cursor = cursorDecode(before); len(cursor) == 2 { diff --git a/plugins/evm/dao/api_cursor_test.go b/plugins/evm/dao/api_cursor_test.go index 2cf6869..3f6d226 100644 --- a/plugins/evm/dao/api_cursor_test.go +++ b/plugins/evm/dao/api_cursor_test.go @@ -75,6 +75,41 @@ func TestAccountsCursorBeforeUsesBeforeCursor(t *testing.T) { assert.Equal(t, true, page["has_next_page"]) } +func TestContractsCursorVerifiedSourceOnly(t *testing.T) { + db := setupAccountsCursorTest(t) + + ctx := context.Background() + contracts := []Contract{ + { + Address: "0x0000000000000000000000000000000000000001", + ContractName: "VerifiedWithSource", + VerifyStatus: "verified", + SourceCode: "pragma solidity ^0.8.0; contract VerifiedWithSource {}", + TransactionCount: 30, + }, + { + Address: "0x0000000000000000000000000000000000000002", + ContractName: "VerifiedWithoutSource", + VerifyStatus: "verified", + TransactionCount: 20, + }, + { + Address: "0x0000000000000000000000000000000000000003", + ContractName: "UnverifiedWithSource", + SourceCode: "pragma solidity ^0.8.0; contract UnverifiedWithSource {}", + TransactionCount: 10, + }, + } + require.NoError(t, db.Create(&contracts).Error) + + list, page := (&ApiSrv{}).ContractsCursor(ctx, 10, nil, nil, true) + + require.Len(t, list, 1) + assert.Equal(t, "VerifiedWithSource", list[0].ContractName) + assert.Equal(t, "verified", list[0].VerifyStatus) + assert.Equal(t, false, page["has_next_page"]) +} + func TestAccountsCursorExcludesSmartContracts(t *testing.T) { db := setupAccountsCursorTest(t) diff --git a/plugins/evm/http/api_test.go b/plugins/evm/http/api_test.go index 1d7768f..f6ee275 100644 --- a/plugins/evm/http/api_test.go +++ b/plugins/evm/http/api_test.go @@ -23,7 +23,7 @@ func (m MockServer) AccountsCursor(ctx context.Context, address string, limit in return nil, nil } -func (m MockServer) ContractsCursor(ctx context.Context, limit int, before, after *string) ([]dao.ContractsJson, map[string]interface{}) { +func (m MockServer) ContractsCursor(ctx context.Context, limit int, before, after *string, verifiedSourceOnly bool) ([]dao.ContractsJson, map[string]interface{}) { return nil, nil } diff --git a/plugins/evm/http/http.go b/plugins/evm/http/http.go index ddcc41d..136b5d7 100644 --- a/plugins/evm/http/http.go +++ b/plugins/evm/http/http.go @@ -342,9 +342,10 @@ func contractHandle(w http.ResponseWriter, r *http.Request) error { } type contractsParams struct { - Limit int `json:"row" validate:"min=1,max=100"` - Before *string `json:"before" validate:"omitempty,min=0"` - After *string `json:"after" validate:"omitempty,min=0"` + Limit int `json:"row" validate:"min=1,max=100"` + Before *string `json:"before" validate:"omitempty,min=0"` + After *string `json:"after" validate:"omitempty,min=0"` + VerifiedSourceOnly bool `json:"verified_source"` } // @Summary Evm contract list @@ -360,7 +361,7 @@ func contractsHandle(w http.ResponseWriter, r *http.Request) error { toJson(w, 10001, nil, err) return nil } - list, page := srv.ContractsCursor(r.Context(), p.Limit, p.Before, p.After) + list, page := srv.ContractsCursor(r.Context(), p.Limit, p.Before, p.After, p.VerifiedSourceOnly) toJson(w, 0, map[string]interface{}{"list": list, "pagination": page}, nil) return nil } diff --git a/ui-react/src/components/contract/contractTable.tsx b/ui-react/src/components/contract/contractTable.tsx index 9bf77ab..2eef0eb 100644 --- a/ui-react/src/components/contract/contractTable.tsx +++ b/ui-react/src/components/contract/contractTable.tsx @@ -1,7 +1,7 @@ -import React, { useMemo } from 'react' +import React from 'react' import { BareProps } from '@/types/page' -import { Table, TableHeader, TableColumn, TableBody, TableRow, TableCell, getKeyValue, Spinner } from '@heroui/react' +import { Table, TableHeader, TableColumn, TableBody, TableRow, TableCell, getKeyValue, Spinner, Switch } from '@heroui/react' import { getPVMContractListParams, unwrap, usePVMContracts } from '@/utils/api' import { PAGE_SIZE } from '@/utils/const' import { useData } from '@/context' @@ -18,11 +18,13 @@ const Component: React.FC = ({ children, className, args }) => { const { metadata, token } = useData() const [page, setPage] = React.useState(1) const [cursor, setCursor] = React.useState<{ after?: number; before?: number }>({}) + const [verifiedSourceOnly, setVerifiedSourceOnly] = React.useState(false) const rowsPerPage = PAGE_SIZE const NEXT_PUBLIC_API_HOST = env('NEXT_PUBLIC_API_HOST') || '' const { data, isLoading } = usePVMContracts(NEXT_PUBLIC_API_HOST, { ...args, row: rowsPerPage, + verified_source: verifiedSourceOnly, ...cursor, }) const contractsData = unwrap(data) @@ -41,46 +43,51 @@ const Component: React.FC = ({ children, className, args }) => { setPage(page + 1) } } + const handleVerifiedSourceChange = (selected: boolean) => { + setVerifiedSourceOnly(selected) + setCursor({}) + setPage(1) + } return ( - - } - classNames={{ - wrapper: 'min-h-[222px]', - td: 'h-[50px]', - }}> - - Contract - Name - Transaction - Status - - } items={items || []} emptyContent={'No data'}> - {(item) => ( - - {(columnKey) => { - if (columnKey === 'address') { - return ( - - {item.address} - - ) - } else if (columnKey === 'verify_status') { - return {item.verify_status === 'verified' ? 'Verified' : 'Unverified'} - } - return {getKeyValue(item, columnKey)} - }} - - )} - -
+
+
+ + Verified source only + +
+ } + classNames={{ + wrapper: 'min-h-[222px]', + td: 'h-[50px]', + }}> + + Contract + Name + Transaction + Status + + } items={items || []} emptyContent={'No data'}> + {(item) => ( + + {(columnKey) => { + if (columnKey === 'address') { + return ( + + {item.address} + + ) + } else if (columnKey === 'verify_status') { + return {item.verify_status === 'verified' ? 'Verified' : 'Unverified'} + } + return {getKeyValue(item, columnKey)} + }} + + )} + +
+
) } diff --git a/ui-react/src/components/contract/info.tsx b/ui-react/src/components/contract/info.tsx index 91f3180..e2d86d0 100644 --- a/ui-react/src/components/contract/info.tsx +++ b/ui-react/src/components/contract/info.tsx @@ -53,7 +53,7 @@ const Component: React.FC = ({ children, className, contract }) => {
Contract Source Code
- + {contract.source_code ? :
No uploaded source code is stored for this contract.
}
diff --git a/ui-react/src/pages/contract/[id].tsx b/ui-react/src/pages/contract/[id].tsx index c2e43f6..1eac4f3 100644 --- a/ui-react/src/pages/contract/[id].tsx +++ b/ui-react/src/pages/contract/[id].tsx @@ -82,7 +82,12 @@ export default function Page() { {contractData.verify_status === 'verified' ? ( ) : ( - +
+
+ Source code has not been uploaded for this contract. +
+ +
)} diff --git a/ui-react/src/utils/api.ts b/ui-react/src/utils/api.ts index 7bc54a5..12e7e2e 100644 --- a/ui-react/src/utils/api.ts +++ b/ui-react/src/utils/api.ts @@ -658,6 +658,7 @@ export type getPVMContractListParams = { row?: number after?: number before?: number + verified_source?: boolean } type getPVMContractParams = { From 738c3d1b789061b735f70a8ddfbae82805454eef Mon Sep 17 00:00:00 2001 From: CrossAgent Date: Thu, 4 Jun 2026 23:05:13 +0800 Subject: [PATCH 09/11] fix: index OmniBridge payout transfers --- plugins/balance/balance.go | 6 +- plugins/balance/dao/event.go | 135 ++++++++++++++++-- plugins/balance/dao/event_test.go | 102 +++++++++++++ plugins/balance/dao/script.go | 73 +++++++++- plugins/balance/model/model.go | 4 + plugins/balance/model/transfer_test.go | 34 +++++ plugins/balance/service/service.go | 11 +- .../src/components/transfer/transferTable.tsx | 30 ++-- ui-react/src/utils/api.ts | 4 + 9 files changed, 370 insertions(+), 29 deletions(-) create mode 100644 plugins/balance/dao/event_test.go create mode 100644 plugins/balance/model/transfer_test.go diff --git a/plugins/balance/balance.go b/plugins/balance/balance.go index a74a4e8..aecc149 100644 --- a/plugins/balance/balance.go +++ b/plugins/balance/balance.go @@ -101,8 +101,8 @@ func (a *Balance) InitHttp() []router.Http { return http.Router(srv) } -func (a *Balance) ProcessExtrinsic(*storage.Block, *storage.Extrinsic, []storage.Event) error { - return nil +func (a *Balance) ProcessExtrinsic(block *storage.Block, _ *storage.Extrinsic, events []storage.Event) error { + return dao.CreateOmniBridgePayoutTransfers(context.TODO(), a.storage(), events, block) } func (a *Balance) ProcessEvent(block *storage.Block, event *storage.Event, _ decimal.Decimal) error { @@ -118,7 +118,7 @@ func (a *Balance) ProcessEvent(block *storage.Block, event *storage.Event, _ dec } func (a *Balance) SubscribeExtrinsic() []string { - return nil + return []string{"omnibridge", "OmniBridge", "Omnibridge"} } func (a *Balance) SubscribeEvent() []string { diff --git a/plugins/balance/dao/event.go b/plugins/balance/dao/event.go index a87daae..cfc56a1 100644 --- a/plugins/balance/dao/event.go +++ b/plugins/balance/dao/event.go @@ -3,12 +3,15 @@ package dao import ( "context" "fmt" + "strings" + subscan_plugin "github.com/itering/subscan-plugin" "github.com/itering/subscan-plugin/storage" "github.com/itering/subscan/model" bModel "github.com/itering/subscan/plugins/balance/model" "github.com/itering/subscan/share/token" "github.com/itering/subscan/util" + "github.com/shopspring/decimal" "gorm.io/gorm" ) @@ -17,6 +20,22 @@ type Storage struct { Pool subscan_plugin.RedisPool } +const ( + TransferCategoryTransfer = "transfer" + TransferCategoryBridgeIn = "bridge_in" + + TransferSourceBalances = "balances" + TransferSourceOmniBridge = "omnibridge" + + TransferEventTransfer = "Transfer" + TransferEventMinted = "Minted" + TransferEventPaidOut = "PaidOut" + + // OmniBridge payout rows use a stable synthetic sender because the chain + // emits incoming funds as balances.Minted without a source account. + OmniBridgeSyntheticSender = "omnibridge" +) + func EmitEvent(ctx context.Context, d *Storage, event *storage.Event, block *storage.Block) error { var paramEvent []storage.EventParam _ = util.UnmarshalAny(¶mEvent, event.Params) @@ -26,23 +45,119 @@ func EmitEvent(ctx context.Context, d *Storage, event *storage.Event, block *sto return RefreshAccount(ctx, d, model.CheckoutParamValueAddress(paramEvent[0].Value)) // ["AccountId","AccountId","Balance"] case "Transfer": - from := model.CheckoutParamValueAddress(paramEvent[0].Value) - to := model.CheckoutParamValueAddress(paramEvent[1].Value) - balance := util.DecimalFromInterface(paramEvent[2].Value) - t := token.GetDefaultToken() - return CreateTransfer(ctx, d, &bModel.Transfer{ + transfer := BalanceTransferFromEvent(event, block) + if transfer == nil { + return nil + } + return CreateTransfer(ctx, d, transfer) + } + return nil +} + +func BalanceTransferFromEvent(event *storage.Event, block *storage.Block) *bModel.Transfer { + if event == nil || !strings.EqualFold(event.ModuleId, TransferSourceBalances) || !strings.EqualFold(event.EventId, TransferEventTransfer) { + return nil + } + var paramEvent []storage.EventParam + _ = util.UnmarshalAny(¶mEvent, event.Params) + if len(paramEvent) < 3 { + return nil + } + t := token.GetDefaultToken() + blockTimestamp := int64(0) + if block != nil { + blockTimestamp = int64(block.BlockTimestamp) + } + return &bModel.Transfer{ + Id: event.Id, + Sender: model.CheckoutParamValueAddress(paramEvent[0].Value), + Receiver: model.CheckoutParamValueAddress(paramEvent[1].Value), + Amount: util.DecimalFromInterface(paramEvent[2].Value), + BlockNum: uint(event.BlockNum), + BlockTimestamp: blockTimestamp, + Symbol: t.Symbol, + TokenId: t.TokenId, + ExtrinsicIndex: fmt.Sprintf("%d-%d", event.BlockNum, event.ExtrinsicIdx), + Category: TransferCategoryTransfer, + SourceModule: TransferSourceBalances, + SourceEvent: TransferEventTransfer, + BalanceEvent: TransferEventTransfer, + } +} + +func CreateOmniBridgePayoutTransfers(ctx context.Context, d *Storage, events []storage.Event, block *storage.Block) error { + for _, transfer := range OmniBridgePayoutTransfers(events, block) { + if err := CreateTransfer(ctx, d, transfer); err != nil { + return err + } + } + return nil +} + +func OmniBridgePayoutTransfers(events []storage.Event, block *storage.Block) []*bModel.Transfer { + if !hasOmniBridgePaidOut(events) { + return nil + } + t := token.GetDefaultToken() + blockTimestamp := int64(0) + if block != nil { + blockTimestamp = int64(block.BlockTimestamp) + } + var transfers []*bModel.Transfer + for index := range events { + event := events[index] + if !strings.EqualFold(event.ModuleId, TransferSourceBalances) || !strings.EqualFold(event.EventId, TransferEventMinted) { + continue + } + var paramEvent []storage.EventParam + _ = util.UnmarshalAny(¶mEvent, event.Params) + if len(paramEvent) < 2 { + continue + } + receiver := model.CheckoutParamValueAddress(paramEvent[0].Value) + if receiver == "" { + continue + } + transfers = append(transfers, &bModel.Transfer{ Id: event.Id, - Sender: from, - Receiver: to, - Amount: balance, + Sender: OmniBridgeSyntheticSender, + Receiver: receiver, + Amount: balanceAmountFromEventParam(paramEvent[1].Value), BlockNum: uint(event.BlockNum), - BlockTimestamp: int64(block.BlockTimestamp), + BlockTimestamp: blockTimestamp, Symbol: t.Symbol, TokenId: t.TokenId, ExtrinsicIndex: fmt.Sprintf("%d-%d", event.BlockNum, event.ExtrinsicIdx), + Category: TransferCategoryBridgeIn, + SourceModule: TransferSourceOmniBridge, + SourceEvent: TransferEventPaidOut, + BalanceEvent: TransferEventMinted, }) } - return nil + return transfers +} + +func hasOmniBridgePaidOut(events []storage.Event) bool { + for index := range events { + event := events[index] + if strings.EqualFold(event.ModuleId, TransferSourceOmniBridge) && strings.EqualFold(event.EventId, TransferEventPaidOut) { + return true + } + } + return false +} + +func balanceAmountFromEventParam(value interface{}) decimal.Decimal { + amount := util.DecimalFromInterface(value) + if !amount.IsZero() { + return amount + } + valueString := strings.TrimSpace(util.ToString(value)) + trimmed := strings.TrimPrefix(valueString, "0x") + if valueString != "" && len(trimmed)%2 == 0 && len(trimmed) >= 32 { + return util.EvmReverseU256Decoder(valueString) + } + return amount } func RefreshMetadata(ctx context.Context, d *Storage) { diff --git a/plugins/balance/dao/event_test.go b/plugins/balance/dao/event_test.go new file mode 100644 index 0000000..c332d6f --- /dev/null +++ b/plugins/balance/dao/event_test.go @@ -0,0 +1,102 @@ +package dao + +import ( + "encoding/json" + "testing" + + "github.com/itering/subscan-plugin/storage" + "github.com/itering/subscan/share/token" + "github.com/shopspring/decimal" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const bridgeReceiver = "00f160c0e8fff2d4f00ab03e18dced9f2ac52a6b865cda497a33aee5b3fe335b" + +func TestBalanceTransferFromEventMarksNormalTransferMetadata(t *testing.T) { + token.SetDefault(&token.Token{Symbol: "HEI", TokenId: "HEI"}) + event := eventWithParams(100000000001, 1000000, 0, TransferSourceBalances, TransferEventTransfer, []storage.EventParam{ + {Type: "AccountId", Value: "242f0781faa44f34ddcbc9e731d0ddb51c97f5b58bb2202090a3a1c679fc4c63"}, + {Type: "AccountId", Value: bridgeReceiver}, + {Type: "Balance", Value: "12345"}, + }) + + transfer := BalanceTransferFromEvent(&event, &storage.Block{BlockTimestamp: 1770000000}) + + require.NotNil(t, transfer) + assert.Equal(t, TransferCategoryTransfer, transfer.Category) + assert.Equal(t, TransferSourceBalances, transfer.SourceModule) + assert.Equal(t, TransferEventTransfer, transfer.SourceEvent) + assert.Equal(t, TransferEventTransfer, transfer.BalanceEvent) + assert.True(t, decimal.RequireFromString("12345").Equal(transfer.Amount)) +} + +func TestOmniBridgePayoutTransfersCreatesBridgeInFromPaidOutAndMinted(t *testing.T) { + token.SetDefault(&token.Token{Symbol: "HEI", TokenId: "HEI"}) + events := []storage.Event{ + eventWithParams(971637600004, 9716376, 2, TransferSourceOmniBridge, TransferEventPaidOut, nil), + eventWithParams(971637600003, 9716376, 2, TransferSourceBalances, TransferEventMinted, []storage.EventParam{ + {Type: "AccountId", Value: bridgeReceiver}, + {Type: "Balance", Value: "0000E8890423C78A0000000000000000"}, + }), + } + + transfers := OmniBridgePayoutTransfers(events, &storage.Block{BlockTimestamp: 1780000000}) + + require.Len(t, transfers, 1) + assert.Equal(t, uint(971637600003), transfers[0].Id) + assert.Equal(t, OmniBridgeSyntheticSender, transfers[0].Sender) + assert.Equal(t, bridgeReceiver, transfers[0].Receiver) + assert.True(t, decimal.RequireFromString("10000000000000000000").Equal(transfers[0].Amount)) + assert.Equal(t, uint(9716376), transfers[0].BlockNum) + assert.Equal(t, int64(1780000000), transfers[0].BlockTimestamp) + assert.Equal(t, "9716376-2", transfers[0].ExtrinsicIndex) + assert.Equal(t, TransferCategoryBridgeIn, transfers[0].Category) + assert.Equal(t, TransferSourceOmniBridge, transfers[0].SourceModule) + assert.Equal(t, TransferEventPaidOut, transfers[0].SourceEvent) + assert.Equal(t, TransferEventMinted, transfers[0].BalanceEvent) +} + +func TestOmniBridgePayoutTransfersIgnoresUnrelatedMinted(t *testing.T) { + token.SetDefault(&token.Token{Symbol: "HEI", TokenId: "HEI"}) + events := []storage.Event{ + eventWithParams(971637600003, 9716376, 2, TransferSourceBalances, TransferEventMinted, []storage.EventParam{ + {Type: "AccountId", Value: bridgeReceiver}, + {Type: "Balance", Value: "10000000000000000000"}, + }), + } + + assert.Empty(t, OmniBridgePayoutTransfers(events, nil)) +} + +func TestOmniBridgePayoutTransfersIsStableForDuplicateReprocessing(t *testing.T) { + token.SetDefault(&token.Token{Symbol: "HEI", TokenId: "HEI"}) + events := []storage.Event{ + eventWithParams(971637600004, 9716376, 2, "OmniBridge", TransferEventPaidOut, nil), + eventWithParams(971637600003, 9716376, 2, "Balances", TransferEventMinted, []storage.EventParam{ + {Type: "AccountId", Value: bridgeReceiver}, + {Type: "Balance", Value: "10000000000000000000"}, + }), + } + + first := OmniBridgePayoutTransfers(events, nil) + second := OmniBridgePayoutTransfers(events, nil) + + require.Len(t, first, 1) + require.Len(t, second, 1) + assert.Equal(t, first[0].Id, second[0].Id) + assert.Equal(t, uint(971637600003), second[0].Id) + assert.True(t, first[0].Amount.Equal(second[0].Amount)) +} + +func eventWithParams(id uint, blockNum int, extrinsicIdx int, moduleID string, eventID string, params []storage.EventParam) storage.Event { + raw, _ := json.Marshal(params) + return storage.Event{ + Id: id, + BlockNum: blockNum, + ExtrinsicIdx: extrinsicIdx, + ModuleId: moduleID, + EventId: eventID, + Params: raw, + } +} diff --git a/plugins/balance/dao/script.go b/plugins/balance/dao/script.go index 5c18299..0f62d38 100644 --- a/plugins/balance/dao/script.go +++ b/plugins/balance/dao/script.go @@ -213,6 +213,7 @@ func RefreshAllAccount(sg *Storage, options ...RefreshAllAccountOptions) error { func InitTransfer(sg *Storage) { c := context.TODO() db := sg.Dao.GetDbInstance().(*gorm.DB) + MarkMissingTransferMetadata(c, db) blockNum, _ := sg.Dao.GetCurrentBlockNum(c) for i := int(blockNum); i >= 0; i -= int(model.SplitTableBlockNum) { @@ -235,16 +236,80 @@ func InitTransfer(sg *Storage) { blocks[b.BlockNum] = b } - var extrinsicIds []string - for _, e := range events { - extrinsicIds = append(extrinsicIds, e.ExtrinsicIndex) - } for index := range events { event := events[index] _ = EmitEvent(c, sg, event.AsPlugin(), blocks[int(event.BlockNum)]) } return nil }) + backfillOmniBridgePayoutTransfers(c, sg, db, tableName) } } + +func MarkMissingTransferMetadata(ctx context.Context, db *gorm.DB) error { + return db.WithContext(ctx). + Model(&bModel.Transfer{}). + Where( + "category = '' OR category IS NULL OR source_module = '' OR source_module IS NULL OR source_event = '' OR source_event IS NULL OR balance_event = '' OR balance_event IS NULL", + ). + Updates(map[string]interface{}{ + "category": TransferCategoryTransfer, + "source_module": TransferSourceBalances, + "source_event": TransferEventTransfer, + "balance_event": TransferEventTransfer, + }).Error +} + +func backfillOmniBridgePayoutTransfers(ctx context.Context, sg *Storage, db *gorm.DB, tableName string) { + var paidOutEvents []*model.ChainEvent + query := db.Table(tableName). + Where("LOWER(module_id) = ?", TransferSourceOmniBridge). + Where("LOWER(event_id) = ?", strings.ToLower(TransferEventPaidOut)) + query.FindInBatches(&paidOutEvents, 50000, func(tx *gorm.DB, batch int) error { + extrinsicSeen := make(map[string]bool) + var extrinsicIds []string + for _, e := range paidOutEvents { + if e.ExtrinsicIndex == "" || extrinsicSeen[e.ExtrinsicIndex] { + continue + } + extrinsicSeen[e.ExtrinsicIndex] = true + extrinsicIds = append(extrinsicIds, e.ExtrinsicIndex) + } + if len(extrinsicIds) == 0 { + return nil + } + + var groupedEvents []*model.ChainEvent + if err := tx.Table(tableName). + Where("extrinsic_index IN ?", extrinsicIds). + Where("(LOWER(module_id) = ? AND LOWER(event_id) = ?) OR (LOWER(module_id) = ? AND LOWER(event_id) = ?)", + TransferSourceOmniBridge, strings.ToLower(TransferEventPaidOut), + TransferSourceBalances, strings.ToLower(TransferEventMinted), + ). + Order("id asc"). + Find(&groupedEvents).Error; err != nil { + return err + } + + var blockNums []uint + eventsByExtrinsic := make(map[string][]storage.Event) + for _, e := range groupedEvents { + eventsByExtrinsic[e.ExtrinsicIndex] = append(eventsByExtrinsic[e.ExtrinsicIndex], *e.AsPlugin()) + if strings.EqualFold(e.ModuleId, TransferSourceBalances) && strings.EqualFold(e.EventId, TransferEventMinted) { + blockNums = append(blockNums, e.BlockNum) + } + } + blocks := make(map[int]*storage.Block) + for _, b := range sg.Dao.GetBlocksByNums(ctx, blockNums, "id,block_num,block_timestamp") { + blocks[b.BlockNum] = b + } + for _, events := range eventsByExtrinsic { + if len(events) == 0 { + continue + } + _ = CreateOmniBridgePayoutTransfers(ctx, sg, events, blocks[events[0].BlockNum]) + } + return nil + }) +} diff --git a/plugins/balance/model/model.go b/plugins/balance/model/model.go index 5f45cb4..6b94a9f 100644 --- a/plugins/balance/model/model.go +++ b/plugins/balance/model/model.go @@ -97,6 +97,10 @@ type Transfer struct { Symbol string `json:"symbol" gorm:"size:255"` TokenId string `json:"token_id" gorm:"size:255"` ExtrinsicIndex string `json:"extrinsic_index" gorm:"size:255;index:extrinsic_index"` + Category string `json:"category" gorm:"size:64;index"` + SourceModule string `json:"source_module" gorm:"size:64;index"` + SourceEvent string `json:"source_event" gorm:"size:64;index"` + BalanceEvent string `json:"balance_event" gorm:"size:64"` } func (a *Transfer) TableName() string { diff --git a/plugins/balance/model/transfer_test.go b/plugins/balance/model/transfer_test.go new file mode 100644 index 0000000..46abda9 --- /dev/null +++ b/plugins/balance/model/transfer_test.go @@ -0,0 +1,34 @@ +package model + +import ( + "encoding/json" + "testing" + + "github.com/shopspring/decimal" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTransferJSONIncludesSourceMetadata(t *testing.T) { + transfer := Transfer{ + Id: 971637600003, + Sender: "omnibridge", + Receiver: "00f160c0e8fff2d4f00ab03e18dced9f2ac52a6b865cda497a33aee5b3fe335b", + Amount: decimal.RequireFromString("10000000000000000000"), + Category: "bridge_in", + SourceModule: "omnibridge", + SourceEvent: "PaidOut", + BalanceEvent: "Minted", + ExtrinsicIndex: "9716376-2", + } + + raw, err := json.Marshal(transfer) + require.NoError(t, err) + + var got map[string]interface{} + require.NoError(t, json.Unmarshal(raw, &got)) + assert.Equal(t, "bridge_in", got["category"]) + assert.Equal(t, "omnibridge", got["source_module"]) + assert.Equal(t, "PaidOut", got["source_event"]) + assert.Equal(t, "Minted", got["balance_event"]) +} diff --git a/plugins/balance/service/service.go b/plugins/balance/service/service.go index d2265c6..8e3ebdb 100644 --- a/plugins/balance/service/service.go +++ b/plugins/balance/service/service.go @@ -52,8 +52,8 @@ func (s *Service) GetTransferCursor(ctx context.Context, addr string, blockNum u } list, hasPrev, hasNext := dao.TransfersCursor(ctx, s.d, limit, before, after, opts...) for index := range list { - list[index].Sender = address.Encode(list[index].Sender) - list[index].Receiver = address.Encode(list[index].Receiver) + list[index].Sender = encodeTransferAddress(list[index].Sender) + list[index].Receiver = encodeTransferAddress(list[index].Receiver) } var start, end *uint if len(list) > 0 { @@ -68,6 +68,13 @@ func (s *Service) GetTransferCursor(ctx context.Context, addr string, blockNum u } } +func encodeTransferAddress(addr string) string { + if address.Format(addr) == "" { + return addr + } + return address.Encode(addr) +} + func New(d storage.Dao, pool subscan_plugin.RedisPool) *Service { return &Service{ d: d, diff --git a/ui-react/src/components/transfer/transferTable.tsx b/ui-react/src/components/transfer/transferTable.tsx index 56503ba..9eb2390 100644 --- a/ui-react/src/components/transfer/transferTable.tsx +++ b/ui-react/src/components/transfer/transferTable.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react' +import React from 'react' import { BareProps } from '@/types/page' import { Table, TableHeader, TableColumn, TableBody, TableRow, TableCell, getKeyValue, Spinner } from '@heroui/react' @@ -15,8 +15,18 @@ interface Props extends BareProps { args?: getTransferListParams } +const categoryLabels: Record = { + transfer: 'Transfer', + bridge_in: 'Bridge In', + bridge_out: 'Bridge Out', +} + +const syntheticSenderLabels: Record = { + omnibridge: 'OmniBridge', +} + const Component: React.FC = ({ children, className, args }) => { - const { metadata, token } = useData() + const { token } = useData() const [page, setPage] = React.useState(1) const [cursor, setCursor] = React.useState<{ after?: number; before?: number }>({}) const rowsPerPage = PAGE_SIZE @@ -46,20 +56,14 @@ const Component: React.FC = ({ children, className, args }) => { return ( - } + bottomContent={} classNames={{ wrapper: 'min-h-[222px]', td: 'h-[50px]', }}> Event ID + Type From To {`Value (${token?.symbol})`} @@ -82,9 +86,15 @@ const Component: React.FC = ({ children, className, args }) => { return {timeAgo(item.block_timestamp)} } else if (columnKey === 'amount') { return {formatBalanceAmount(new BigNumber(item.amount), token?.decimals)} + } else if (columnKey === 'category') { + return {categoryLabels[item.category || 'transfer'] || item.category || 'Transfer'} } if (columnKey === 'sender' || columnKey === 'receiver') { const address = columnKey === 'sender' ? item.sender : item.receiver + const syntheticLabel = syntheticSenderLabels[address] + if (syntheticLabel) { + return {syntheticLabel} + } return ( diff --git a/ui-react/src/utils/api.ts b/ui-react/src/utils/api.ts index 12e7e2e..39e10d9 100644 --- a/ui-react/src/utils/api.ts +++ b/ui-react/src/utils/api.ts @@ -227,10 +227,14 @@ export type transferType = { amount: string blockNum: number block_timestamp: number + balance_event?: string + category?: string extrinsic_index: string id: number receiver: string sender: string + source_event?: string + source_module?: string symbol: string token_id: string } From c1c010115712d061b63b7cc970e71b82492c7db1 Mon Sep 17 00:00:00 2001 From: Xin Date: Wed, 10 Jun 2026 21:12:57 +0800 Subject: [PATCH 10/11] Fix contract detail balance display --- docs/api/docs.go | 3 + docs/api/swagger.json | 3 + docs/api/swagger.yaml | 2 + plugins/evm/dao/api.go | 86 +++++++++++++++++-- plugins/evm/dao/api_cursor_test.go | 31 ++++++- plugins/evm/http/accounts_e2e_test.go | 15 ++++ plugins/evm/http/api_test.go | 2 +- plugins/evm/http/http.go | 11 +-- .../cursorPagination/cursorPagination.tsx | 4 +- .../components/pvmAccount/accountTable.tsx | 6 +- ui-react/src/pages/contract/[id].tsx | 1 + ui-react/src/utils/api.ts | 9 +- 12 files changed, 146 insertions(+), 27 deletions(-) diff --git a/docs/api/docs.go b/docs/api/docs.go index 417a2c3..976e34e 100644 --- a/docs/api/docs.go +++ b/docs/api/docs.go @@ -2531,6 +2531,9 @@ const docTemplate = `{ "type": "string", "minLength": 0 }, + "include_contracts": { + "type": "boolean" + }, "row": { "type": "integer", "maximum": 100, diff --git a/docs/api/swagger.json b/docs/api/swagger.json index 5a091bb..98bef7b 100644 --- a/docs/api/swagger.json +++ b/docs/api/swagger.json @@ -2520,6 +2520,9 @@ "type": "string", "minLength": 0 }, + "include_contracts": { + "type": "boolean" + }, "row": { "type": "integer", "maximum": 100, diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml index 0e4dcfe..8709858 100644 --- a/docs/api/swagger.yaml +++ b/docs/api/swagger.yaml @@ -739,6 +739,8 @@ definitions: before: minLength: 0 type: string + include_contracts: + type: boolean row: maximum: 100 minimum: 1 diff --git a/plugins/evm/dao/api.go b/plugins/evm/dao/api.go index 379b32b..cb2b864 100644 --- a/plugins/evm/dao/api.go +++ b/plugins/evm/dao/api.go @@ -4,7 +4,10 @@ import ( "context" "fmt" "github.com/itering/subscan/model" + "github.com/itering/subscan/pkg/go-web3/complex/types" + "github.com/itering/subscan/pkg/go-web3/dto" balanceModel "github.com/itering/subscan/plugins/balance/model" + "github.com/itering/subscan/share/web3" "github.com/itering/subscan/util" "github.com/shopspring/decimal" "strings" @@ -25,7 +28,7 @@ type ISrv interface { BlockByNum(ctx context.Context, blockNum uint) *EvmBlock BlockByHash(ctx context.Context, hash string) *EvmBlock TransactionsCursor(ctx context.Context, limit int, before, after *uint, opts ...model.Option) ([]TransactionSampleJson, map[string]interface{}) - AccountsCursor(ctx context.Context, address string, limit int, before, after *string) ([]AccountsJson, map[string]interface{}) + AccountsCursor(ctx context.Context, address string, includeContracts bool, limit int, before, after *string) ([]AccountsJson, map[string]interface{}) ContractsCursor(ctx context.Context, limit int, before, after *string, verifiedSourceOnly bool) ([]ContractsJson, map[string]interface{}) AccountTokens(ctx context.Context, address, category string) []AccountTokenJson @@ -116,11 +119,11 @@ func transactionReceiptsToEtherscanLogs(ctx context.Context, list []TransactionR return } -func (a *ApiSrv) API_GetAccounts(ctx context.Context, h160 []string) (map[string]balanceModel.Account, error) { +func (a *ApiSrv) API_GetAccounts(ctx context.Context, h160s []string) (map[string]balanceModel.Account, error) { var addresses []string var addr2H160 = make(map[string]string) - for _, v := range h160 { + for _, v := range h160s { addr := h160ToAccountIdByNetwork(ctx, v, util.NetworkNode) if addr == "" { return nil, fmt.Errorf("address %s not a valid address", v) @@ -136,6 +139,14 @@ func (a *ApiSrv) API_GetAccounts(ctx context.Context, h160 []string) (map[string for _, v := range accounts { accountMap[addr2H160[v.Address]] = v } + for _, h160 := range h160s { + if balance, ok := latestEvmNativeBalance(ctx, h160); ok { + account := accountMap[h160] + account.Address = h160ToAccountIdByNetwork(ctx, h160, util.NetworkNode) + account.Balance = balance + accountMap[h160] = account + } + } return accountMap, nil } @@ -488,15 +499,24 @@ func (a AccountsJson) Cursor() string { return util.Base64Encode(fmt.Sprintf("%s_%s", a.Balance.String(), a.EvmAccount)) } -func (a *ApiSrv) AccountsCursor(ctx context.Context, address string, limit int, before, after *string) ([]AccountsJson, map[string]interface{}) { +func (a *ApiSrv) AccountsCursor(ctx context.Context, address string, includeContracts bool, limit int, before, after *string) ([]AccountsJson, map[string]interface{}) { var list []AccountsJson fetch := limit + 1 + singleAddressWithContracts := includeContracts && address != "" + selectClause := "evm_accounts.evm_account,balance" + balanceJoin := "join balance_accounts on evm_accounts.address=balance_accounts.address" + if singleAddressWithContracts { + selectClause = "evm_accounts.evm_account,COALESCE(balance_accounts.balance,0) as balance" + balanceJoin = "left join balance_accounts on evm_accounts.address=balance_accounts.address" + } q := sg.db.WithContext(ctx). - Select("evm_accounts.evm_account,balance"). + Select(selectClause). Model(&Account{}). - Joins("join balance_accounts on evm_accounts.address=balance_accounts.address"). - Joins("left join evm_contracts on evm_contracts.address=evm_accounts.evm_account"). - Where("evm_contracts.address IS NULL") + Joins(balanceJoin) + if !includeContracts { + q = q.Joins("left join evm_contracts on evm_contracts.address=evm_accounts.evm_account"). + Where("evm_contracts.address IS NULL") + } if address != "" { q = q.Where("evm_account = ?", address) } @@ -508,6 +528,15 @@ func (a *ApiSrv) AccountsCursor(ctx context.Context, address string, limit int, q = q.Order("balance desc").Order("balance_accounts.address desc") } q.Limit(fetch).Scan(&list) + if singleAddressWithContracts { + if balance, ok := latestEvmContractDisplayBalance(ctx, address); ok { + if len(list) == 0 { + list = append(list, AccountsJson{EvmAccount: address, Balance: balance}) + } else { + list[0].Balance = balance + } + } + } var hasPrev, hasNext bool if before != nil && *before != "" { hasPrev = len(list) > limit @@ -535,6 +564,47 @@ func (a *ApiSrv) AccountsCursor(ctx context.Context, address string, limit int, return list, map[string]interface{}{"start_cursor": start, "end_cursor": end, "has_previous_page": hasPrev, "has_next_page": hasNext} } +func latestEvmContractDisplayBalance(ctx context.Context, address string) (decimal.Decimal, bool) { + nativeBalance, nativeOK := latestEvmNativeBalance(ctx, address) + depositBalance, depositOK := latestEvmContractDepositBalance(ctx, address) + if depositOK { + if nativeOK { + return nativeBalance.Add(depositBalance), true + } + return depositBalance, true + } + return nativeBalance, nativeOK +} + +func latestEvmNativeBalance(ctx context.Context, address string) (decimal.Decimal, bool) { + if web3.RPC == nil || web3.RPC.Eth == nil { + return decimal.Zero, false + } + balance, err := web3.RPC.Eth.GetBalance(ctx, address, "latest") + if err != nil || balance == nil { + return decimal.Zero, false + } + return decimal.NewFromBigInt(balance, 0), true +} + +func latestEvmContractDepositBalance(ctx context.Context, address string) (decimal.Decimal, bool) { + if web3.RPC == nil || web3.RPC.Eth == nil { + return decimal.Zero, false + } + result, err := web3.RPC.Eth.Call(ctx, &dto.TransactionParameters{ + To: address, + Data: types.ComplexString("0xc399ec88"), // getDeposit() + }) + if err != nil || result == nil { + return decimal.Zero, false + } + balance, err := result.ToBigInt() + if err != nil || balance == nil { + return decimal.Zero, false + } + return decimal.NewFromBigInt(balance, 0), true +} + type ContractsJson struct { ContractName string `json:"contract_name"` Address string `json:"address"` diff --git a/plugins/evm/dao/api_cursor_test.go b/plugins/evm/dao/api_cursor_test.go index 3f6d226..04550bb 100644 --- a/plugins/evm/dao/api_cursor_test.go +++ b/plugins/evm/dao/api_cursor_test.go @@ -37,7 +37,7 @@ func TestAccountsCursorFiltersByEvmAccount(t *testing.T) { require.NoError(t, db.Create(&balanceModel.Account{Address: "target-account", Balance: decimal.NewFromInt(5)}).Error) require.NoError(t, db.Create(&balanceModel.Account{Address: "other-account", Balance: decimal.NewFromInt(10)}).Error) - list, page := (&ApiSrv{}).AccountsCursor(ctx, target, 10, nil, nil) + list, page := (&ApiSrv{}).AccountsCursor(ctx, target, false, 10, nil, nil) require.Len(t, list, 1) assert.Equal(t, target, list[0].EvmAccount) @@ -67,7 +67,7 @@ func TestAccountsCursorBeforeUsesBeforeCursor(t *testing.T) { EvmAccount: "0x0000000000000000000000000000000000000002", Balance: decimal.NewFromInt(20), }.Cursor() - list, page := (&ApiSrv{}).AccountsCursor(ctx, "", 10, &cursor, nil) + list, page := (&ApiSrv{}).AccountsCursor(ctx, "", false, 10, &cursor, nil) require.Len(t, list, 1) assert.Equal(t, accounts[0].account, list[0].EvmAccount) @@ -123,15 +123,38 @@ func TestAccountsCursorExcludesSmartContracts(t *testing.T) { require.NoError(t, db.Create(&balanceModel.Account{Address: "substrate-contract", Balance: decimal.NewFromInt(20)}).Error) require.NoError(t, db.Session(&gorm.Session{SkipHooks: true}).Create(&Contract{Address: contract}).Error) - list, page := (&ApiSrv{}).AccountsCursor(ctx, "", 10, nil, nil) + list, page := (&ApiSrv{}).AccountsCursor(ctx, "", false, 10, nil, nil) require.Len(t, list, 1) assert.Equal(t, eoa, list[0].EvmAccount) assert.Equal(t, decimal.NewFromInt(10), list[0].Balance) assert.Equal(t, false, page["has_next_page"]) - list, page = (&ApiSrv{}).AccountsCursor(ctx, contract, 10, nil, nil) + list, page = (&ApiSrv{}).AccountsCursor(ctx, contract, false, 10, nil, nil) assert.Empty(t, list) assert.Nil(t, page["start_cursor"]) assert.Nil(t, page["end_cursor"]) + + list, page = (&ApiSrv{}).AccountsCursor(ctx, contract, true, 10, nil, nil) + require.Len(t, list, 1) + assert.Equal(t, contract, list[0].EvmAccount) + assert.Equal(t, decimal.NewFromInt(20), list[0].Balance) + assert.Equal(t, false, page["has_next_page"]) +} + +func TestAccountsCursorIncludesContractWithoutIndexedBalance(t *testing.T) { + db := setupAccountsCursorTest(t) + + ctx := context.Background() + contract := "0x0000000000000000000000000000000000000003" + + require.NoError(t, db.Create(&Account{Address: "substrate-contract", EvmAccount: contract}).Error) + require.NoError(t, db.Session(&gorm.Session{SkipHooks: true}).Create(&Contract{Address: contract}).Error) + + list, page := (&ApiSrv{}).AccountsCursor(ctx, contract, true, 10, nil, nil) + + require.Len(t, list, 1) + assert.Equal(t, contract, list[0].EvmAccount) + assert.True(t, list[0].Balance.IsZero()) + assert.Equal(t, false, page["has_next_page"]) } diff --git a/plugins/evm/http/accounts_e2e_test.go b/plugins/evm/http/accounts_e2e_test.go index 3663a94..ea47ff6 100644 --- a/plugins/evm/http/accounts_e2e_test.go +++ b/plugins/evm/http/accounts_e2e_test.go @@ -61,6 +61,21 @@ func TestAccountsRouteExcludesSmartContracts(t *testing.T) { assert.Equal(t, eoa, response.Data.List[0].EvmAccount) assert.NotEqual(t, contract, response.Data.List[0].EvmAccount) + request = httptest.NewRequest( + nethttp.MethodPost, + "/api/plugin/evm/accounts", + strings.NewReader(`{"address":"`+contract+`","row":10,"include_contracts":true}`), + ) + recorder = httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + require.Equal(t, nethttp.StatusOK, recorder.Code) + + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response)) + require.Zero(t, response.Code) + require.Len(t, response.Data.List, 1) + assert.Equal(t, contract, response.Data.List[0].EvmAccount) + assert.Equal(t, decimal.NewFromInt(20), response.Data.List[0].Balance) + var pretty bytes.Buffer require.NoError(t, json.Indent(&pretty, recorder.Body.Bytes(), "", " ")) t.Logf("POST /api/plugin/evm/accounts response with seeded evm_accounts and evm_contracts:\n%s", pretty.String()) diff --git a/plugins/evm/http/api_test.go b/plugins/evm/http/api_test.go index f6ee275..52e4797 100644 --- a/plugins/evm/http/api_test.go +++ b/plugins/evm/http/api_test.go @@ -19,7 +19,7 @@ func (m MockServer) TransactionsCursor(ctx context.Context, limit int, before, a return nil, nil } -func (m MockServer) AccountsCursor(ctx context.Context, address string, limit int, before, after *string) ([]dao.AccountsJson, map[string]interface{}) { +func (m MockServer) AccountsCursor(ctx context.Context, address string, includeContracts bool, limit int, before, after *string) ([]dao.AccountsJson, map[string]interface{}) { return nil, nil } diff --git a/plugins/evm/http/http.go b/plugins/evm/http/http.go index 136b5d7..37f84a6 100644 --- a/plugins/evm/http/http.go +++ b/plugins/evm/http/http.go @@ -291,10 +291,11 @@ func transactionsHandle(w http.ResponseWriter, r *http.Request) error { } type EvmAccountParams struct { - Limit int `json:"row" validate:"min=1,max=100"` - Before *string `json:"before" validate:"omitempty,min=0"` - After *string `json:"after" validate:"omitempty,min=0"` - Address string `json:"address" validate:"omitempty,eth_addr"` + Limit int `json:"row" validate:"min=1,max=100"` + Before *string `json:"before" validate:"omitempty,min=0"` + After *string `json:"after" validate:"omitempty,min=0"` + Address string `json:"address" validate:"omitempty,eth_addr"` + IncludeContracts bool `json:"include_contracts"` } // @Summary Evm accounts list @@ -310,7 +311,7 @@ func accountsHandle(w http.ResponseWriter, r *http.Request) error { toJson(w, 10001, nil, err) return nil } - list, page := srv.AccountsCursor(r.Context(), p.Address, p.Limit, p.Before, p.After) + list, page := srv.AccountsCursor(r.Context(), p.Address, p.IncludeContracts, p.Limit, p.Before, p.After) toJson(w, 0, map[string]interface{}{"list": list, "pagination": page}, nil) return nil } diff --git a/ui-react/src/components/cursorPagination/cursorPagination.tsx b/ui-react/src/components/cursorPagination/cursorPagination.tsx index 9989b01..1736a17 100644 --- a/ui-react/src/components/cursorPagination/cursorPagination.tsx +++ b/ui-react/src/components/cursorPagination/cursorPagination.tsx @@ -4,8 +4,8 @@ import { BareProps } from '@/types/page' import { themeType } from '@/utils/text' interface PaginationInfo { - start_cursor: number - end_cursor: number + start_cursor: number | string + end_cursor: number | string has_next_page: boolean has_previous_page: boolean } diff --git a/ui-react/src/components/pvmAccount/accountTable.tsx b/ui-react/src/components/pvmAccount/accountTable.tsx index d528aa9..dfb9886 100644 --- a/ui-react/src/components/pvmAccount/accountTable.tsx +++ b/ui-react/src/components/pvmAccount/accountTable.tsx @@ -3,7 +3,7 @@ import React, { useMemo } from 'react' import { BareProps } from '@/types/page' import { Table, TableHeader, TableColumn, TableBody, TableRow, TableCell, getKeyValue, Spinner } from '@heroui/react' import { formatBalanceAmount, getThemeColor } from '@/utils/text' -import { getExtrinsicListParams, unwrap, usePVMAccounts } from '@/utils/api' +import { getPVMAccountListParams, unwrap, usePVMAccounts } from '@/utils/api' import { PAGE_SIZE } from '@/utils/const' import { useData } from '@/context' import BigNumber from 'bignumber.js' @@ -12,13 +12,13 @@ import { CursorPagination } from '../cursorPagination' import { env } from 'next-runtime-env' interface Props extends BareProps { - args?: getExtrinsicListParams + args?: getPVMAccountListParams } const Component: React.FC = ({ children, className, args }) => { const { metadata, token } = useData() const [page, setPage] = React.useState(1) - const [cursor, setCursor] = React.useState<{ after?: number; before?: number }>({}) + const [cursor, setCursor] = React.useState<{ after?: string; before?: string }>({}) const rowsPerPage = PAGE_SIZE const NEXT_PUBLIC_API_HOST = env('NEXT_PUBLIC_API_HOST') || '' const { data, isLoading } = usePVMAccounts(NEXT_PUBLIC_API_HOST, { diff --git a/ui-react/src/pages/contract/[id].tsx b/ui-react/src/pages/contract/[id].tsx index 1eac4f3..a8ab165 100644 --- a/ui-react/src/pages/contract/[id].tsx +++ b/ui-react/src/pages/contract/[id].tsx @@ -24,6 +24,7 @@ export default function Page() { const { data: accountsData, isLoading } = usePVMAccounts(NEXT_PUBLIC_API_HOST, { address: id, + include_contracts: true, row: 10, page: 0, }) diff --git a/ui-react/src/utils/api.ts b/ui-react/src/utils/api.ts index 39e10d9..dfdd1ea 100644 --- a/ui-react/src/utils/api.ts +++ b/ui-react/src/utils/api.ts @@ -462,8 +462,8 @@ export type pvmAccountListType = { list: pvmAccountType[] | null count: number pagination: { - start_cursor: number, - end_cursor: number, + start_cursor: string, + end_cursor: string, has_next_page: boolean, has_previous_page: boolean } @@ -472,9 +472,10 @@ export type pvmAccountListType = { export type getPVMAccountListParams = { page?: number row?: number - after?: number - before?: number + after?: string + before?: string address?: string + include_contracts?: boolean } export const usePVMAccounts = (host: string, data: getPVMAccountListParams) => { From d3d79af4830eea6ec98e3d599ea5e284c6d1e16f Mon Sep 17 00:00:00 2001 From: Xin Date: Thu, 11 Jun 2026 10:59:30 +0800 Subject: [PATCH 11/11] Use native balance for contract detail --- plugins/evm/dao/api.go | 34 +--------------------------------- 1 file changed, 1 insertion(+), 33 deletions(-) diff --git a/plugins/evm/dao/api.go b/plugins/evm/dao/api.go index cb2b864..1c4e0c9 100644 --- a/plugins/evm/dao/api.go +++ b/plugins/evm/dao/api.go @@ -4,8 +4,6 @@ import ( "context" "fmt" "github.com/itering/subscan/model" - "github.com/itering/subscan/pkg/go-web3/complex/types" - "github.com/itering/subscan/pkg/go-web3/dto" balanceModel "github.com/itering/subscan/plugins/balance/model" "github.com/itering/subscan/share/web3" "github.com/itering/subscan/util" @@ -529,7 +527,7 @@ func (a *ApiSrv) AccountsCursor(ctx context.Context, address string, includeCont } q.Limit(fetch).Scan(&list) if singleAddressWithContracts { - if balance, ok := latestEvmContractDisplayBalance(ctx, address); ok { + if balance, ok := latestEvmNativeBalance(ctx, address); ok { if len(list) == 0 { list = append(list, AccountsJson{EvmAccount: address, Balance: balance}) } else { @@ -564,18 +562,6 @@ func (a *ApiSrv) AccountsCursor(ctx context.Context, address string, includeCont return list, map[string]interface{}{"start_cursor": start, "end_cursor": end, "has_previous_page": hasPrev, "has_next_page": hasNext} } -func latestEvmContractDisplayBalance(ctx context.Context, address string) (decimal.Decimal, bool) { - nativeBalance, nativeOK := latestEvmNativeBalance(ctx, address) - depositBalance, depositOK := latestEvmContractDepositBalance(ctx, address) - if depositOK { - if nativeOK { - return nativeBalance.Add(depositBalance), true - } - return depositBalance, true - } - return nativeBalance, nativeOK -} - func latestEvmNativeBalance(ctx context.Context, address string) (decimal.Decimal, bool) { if web3.RPC == nil || web3.RPC.Eth == nil { return decimal.Zero, false @@ -587,24 +573,6 @@ func latestEvmNativeBalance(ctx context.Context, address string) (decimal.Decima return decimal.NewFromBigInt(balance, 0), true } -func latestEvmContractDepositBalance(ctx context.Context, address string) (decimal.Decimal, bool) { - if web3.RPC == nil || web3.RPC.Eth == nil { - return decimal.Zero, false - } - result, err := web3.RPC.Eth.Call(ctx, &dto.TransactionParameters{ - To: address, - Data: types.ComplexString("0xc399ec88"), // getDeposit() - }) - if err != nil || result == nil { - return decimal.Zero, false - } - balance, err := result.ToBigInt() - if err != nil || balance == nil { - return decimal.Zero, false - } - return decimal.NewFromBigInt(balance, 0), true -} - type ContractsJson struct { ContractName string `json:"contract_name"` Address string `json:"address"`