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 703f03f..d80e52c 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) ([]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 2cf6869..dfeab4d 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) @@ -88,15 +88,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 1d7768f..30808b1 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 ddcc41d..c7f5cf3 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 ff4fca5..5d1794c 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 { getBalanceAmount, 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 6655b7f..cc2baf8 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 29c5d06..96b84d6 100644 --- a/ui-react/src/utils/api.ts +++ b/ui-react/src/utils/api.ts @@ -450,8 +450,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 } @@ -460,9 +460,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) => {