From ed9bed5dc2e99f7980669fbbff6aba22fe8d2b6e Mon Sep 17 00:00:00 2001 From: pragmaxim Date: Thu, 26 Mar 2026 08:26:41 +0100 Subject: [PATCH 1/2] fix: honor tokens-to-return on address token balances --- api/worker.go | 84 ++++++++++++---- api/worker_token_filter_test.go | 167 +++++++++++++++++++++++++++++++- docs/api.md | 8 +- server/public_test.go | 90 +++++++++++++++++ 4 files changed, 326 insertions(+), 23 deletions(-) diff --git a/api/worker.go b/api/worker.go index 7e15b71622..646ee133d1 100644 --- a/api/worker.go +++ b/api/worker.go @@ -1116,19 +1116,6 @@ func (w *Worker) getEthereumContractBalance(addrDesc bchain.AddressDescriptor, i return &t, nil } -func hasEthereumTokenHoldingsField(t *Token) bool { - if t == nil { - return false - } - if t.BalanceSat != nil { - return true - } - if len(t.Ids) > 0 { - return true - } - return len(t.MultiTokenValues) > 0 -} - // a fallback method in case internal transactions are not processed and there is no indexed info about contract balance for an address func (w *Worker) getEthereumContractBalanceFromBlockchain(addrDesc, contract bchain.AddressDescriptor, details AccountDetails) (*Token, error) { var b *big.Int @@ -1185,6 +1172,59 @@ func (w *Worker) GetContractBaseRate(ticker *common.CurrencyRatesTicker, token s return float64(rate), found } +func hasEthereumTokenNonzeroHoldings(t *Token) bool { + if t == nil { + return false + } + // EVM token families expose current holdings in different fields: + // ERC20 uses BalanceSat, ERC721 uses Ids, ERC1155 uses MultiTokenValues. + if t.BalanceSat != nil && !IsZeroBigInt((*big.Int)(t.BalanceSat)) { + return true + } + if len(t.Ids) > 0 { + return true + } + for i := range t.MultiTokenValues { + if t.MultiTokenValues[i].Value == nil || !IsZeroBigInt((*big.Int)(t.MultiTokenValues[i].Value)) { + return true + } + } + return false +} + +func hasEthereumTokenHoldingsField(t *Token) bool { + if t == nil { + return false + } + return t.BalanceSat != nil || len(t.Ids) > 0 || len(t.MultiTokenValues) > 0 +} + +func shouldIncludeEthereumAddressToken(t *Token, details AccountDetails, mode TokensToReturn) bool { + if t == nil { + return false + } + switch mode { + case TokensToReturnUsed, TokensToReturnDerived: + if details >= AccountDetailsTokenBalances && + (t.Standard == bchain.ERC771TokenStandard || t.Standard == bchain.ERC1155TokenStandard) { + // Keep the legacy address behavior for NFT-like contracts so websocket + // callers that rely on the default derived mode do not suddenly see + // metadata-only ERC721/ERC1155 entries with no current holdings field. + return hasEthereumTokenHoldingsField(t) + } + return true + case TokensToReturnNonzeroBalance: + // The "tokens" detail level returns contract metadata only; do not turn + // it into a balance-fetching path just to enforce nonzero filtering. + if details < AccountDetailsTokenBalances { + return true + } + return hasEthereumTokenNonzeroHoldings(t) + default: + return true + } +} + type ethereumTypeAddressData struct { tokens Tokens contractInfo *bchain.ContractInfo @@ -1229,6 +1269,9 @@ func (w *Worker) getEthereumTypeAddressBalances(addrDesc bchain.AddressDescripto return nil, nil, NewAPIError(fmt.Sprintf("Invalid contract filter, %v", err), true) } } + // Track indexed contract matches separately so token filtering does not + // accidentally hide contract-specific transaction history. + indexedContractMatched := false if ca != nil { // Address has indexed contract/tx data; include totals and nonce. ba = &db.AddrBalance{ @@ -1281,6 +1324,7 @@ func (w *Worker) getEthereumTypeAddressBalances(addrDesc bchain.AddressDescripto if !bytes.Equal(filterDesc, c.Contract) { continue } + indexedContractMatched = true // filter only transactions of this contract filter.Vout = i + db.ContractIndexOffset } @@ -1293,9 +1337,9 @@ func (w *Worker) getEthereumTypeAddressBalances(addrDesc bchain.AddressDescripto if err != nil { return nil, nil, err } - // tokenBalances responses should not contain metadata-only tokens - // without any holdings field. - if details >= AccountDetailsTokenBalances && !hasEthereumTokenHoldingsField(t) { + // Apply tokens=nonzero|used|derived at response assembly time so the + // same indexed contract data can back different API views. + if !shouldIncludeEthereumAddressToken(t, details, filter.TokensToReturn) { continue } d.tokens[j] = *t @@ -1343,13 +1387,17 @@ func (w *Worker) getEthereumTypeAddressBalances(addrDesc bchain.AddressDescripto // returns 0 for unknown address d.nonce = strconv.Itoa(int(n)) // special handling if filtering for a contract, return the contract details even though the address had no transactions with it - if len(d.tokens) == 0 && len(filterDesc) > 0 && details >= AccountDetailsTokens { + if !indexedContractMatched && len(filterDesc) > 0 && details >= AccountDetailsTokens { // Query the backend directly to return contract metadata/balance for filtered views. t, err := w.getEthereumContractBalanceFromBlockchain(addrDesc, filterDesc, details) if err != nil { return nil, nil, err } - d.tokens = []Token{*t} + // Keep the fallback token list consistent with the indexed path while + // leaving contract-specific tx history controlled by indexedContractMatched. + if shouldIncludeEthereumAddressToken(t, details, filter.TokensToReturn) { + d.tokens = []Token{*t} + } // switch off query for transactions, there are no transactions filter.Vout = AddressFilterVoutQueryNotNecessary d.totalResults = -1 diff --git a/api/worker_token_filter_test.go b/api/worker_token_filter_test.go index 4f6848b56c..c61a26f79f 100644 --- a/api/worker_token_filter_test.go +++ b/api/worker_token_filter_test.go @@ -5,9 +5,11 @@ package api import ( "math/big" "testing" + + "github.com/trezor/blockbook/bchain" ) -func TestHasEthereumTokenHoldingsField(t *testing.T) { +func TestHasEthereumTokenNonzeroHoldings(t *testing.T) { tests := []struct { name string token *Token @@ -24,9 +26,9 @@ func TestHasEthereumTokenHoldingsField(t *testing.T) { want: false, }, { - name: "erc20 zero balance still has field", + name: "erc20 zero balance", token: &Token{BalanceSat: (*Amount)(big.NewInt(0))}, - want: true, + want: false, }, { name: "erc20 nonzero balance", @@ -39,7 +41,55 @@ func TestHasEthereumTokenHoldingsField(t *testing.T) { want: true, }, { - name: "erc1155 multi token values", + name: "erc1155 zero value only", + token: &Token{MultiTokenValues: []MultiTokenValue{{Value: (*Amount)(big.NewInt(0))}}}, + want: false, + }, + { + name: "erc1155 nonzero value", + token: &Token{MultiTokenValues: []MultiTokenValue{{Value: (*Amount)(big.NewInt(7))}}}, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := hasEthereumTokenNonzeroHoldings(tt.token) + if got != tt.want { + t.Fatalf("hasEthereumTokenNonzeroHoldings() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestHasEthereumTokenHoldingsField(t *testing.T) { + tests := []struct { + name string + token *Token + want bool + }{ + { + name: "nil token", + token: nil, + want: false, + }, + { + name: "metadata only", + token: &Token{}, + want: false, + }, + { + name: "erc20 zero balance", + token: &Token{BalanceSat: (*Amount)(big.NewInt(0))}, + want: true, + }, + { + name: "erc721 ids", + token: &Token{Ids: []Amount{Amount(*big.NewInt(0))}}, + want: true, + }, + { + name: "erc1155 zero value field", token: &Token{MultiTokenValues: []MultiTokenValue{{Value: (*Amount)(big.NewInt(0))}}}, want: true, }, @@ -54,3 +104,112 @@ func TestHasEthereumTokenHoldingsField(t *testing.T) { }) } } + +func TestShouldIncludeEthereumAddressToken(t *testing.T) { + tests := []struct { + name string + token *Token + details AccountDetails + mode TokensToReturn + want bool + }{ + { + name: "nonzero keeps metadata token for tokens detail", + token: &Token{}, + details: AccountDetailsTokens, + mode: TokensToReturnNonzeroBalance, + want: true, + }, + { + name: "nonzero drops metadata token for token balances", + token: &Token{}, + details: AccountDetailsTokenBalances, + mode: TokensToReturnNonzeroBalance, + want: false, + }, + { + name: "nonzero drops zero erc20 balance", + token: &Token{BalanceSat: (*Amount)(big.NewInt(0))}, + details: AccountDetailsTokenBalances, + mode: TokensToReturnNonzeroBalance, + want: false, + }, + { + name: "nonzero keeps nonzero erc20 balance", + token: &Token{BalanceSat: (*Amount)(big.NewInt(5))}, + details: AccountDetailsTokenBalances, + mode: TokensToReturnNonzeroBalance, + want: true, + }, + { + name: "nonzero keeps erc721 ids", + token: &Token{Ids: []Amount{Amount(*big.NewInt(1))}}, + details: AccountDetailsTokenBalances, + mode: TokensToReturnNonzeroBalance, + want: true, + }, + { + name: "nonzero keeps erc1155 nonzero value", + token: &Token{MultiTokenValues: []MultiTokenValue{ + {Value: (*Amount)(big.NewInt(9))}, + }}, + details: AccountDetailsTokenBalances, + mode: TokensToReturnNonzeroBalance, + want: true, + }, + { + name: "used keeps zero erc20 balance", + token: &Token{Standard: bchain.ERC20TokenStandard, BalanceSat: (*Amount)(big.NewInt(0))}, + details: AccountDetailsTokenBalances, + mode: TokensToReturnUsed, + want: true, + }, + { + name: "derived keeps zero erc20 balance", + token: &Token{Standard: bchain.ERC20TokenStandard, BalanceSat: (*Amount)(big.NewInt(0))}, + details: AccountDetailsTokenBalances, + mode: TokensToReturnDerived, + want: true, + }, + { + name: "used keeps metadata-only erc20 token", + token: &Token{Standard: bchain.ERC20TokenStandard}, + details: AccountDetailsTokenBalances, + mode: TokensToReturnUsed, + want: true, + }, + { + name: "used drops empty erc721 contract", + token: &Token{Standard: bchain.ERC771TokenStandard}, + details: AccountDetailsTokenBalances, + mode: TokensToReturnUsed, + want: false, + }, + { + name: "derived drops empty erc1155 contract", + token: &Token{Standard: bchain.ERC1155TokenStandard}, + details: AccountDetailsTokenBalances, + mode: TokensToReturnDerived, + want: false, + }, + { + name: "derived keeps erc1155 holdings field", + token: &Token{ + Standard: bchain.ERC1155TokenStandard, + MultiTokenValues: []MultiTokenValue{{Value: (*Amount)(big.NewInt(0))}}, + }, + details: AccountDetailsTokenBalances, + mode: TokensToReturnDerived, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := shouldIncludeEthereumAddressToken(tt.token, tt.details, tt.mode) + if got != tt.want { + t.Fatalf("shouldIncludeEthereumAddressToken() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/docs/api.md b/docs/api.md index e41780a29f..b3a193437f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -461,7 +461,7 @@ Example response: Returns balances and transactions of an address. The returned transactions are sorted by block height, newest blocks first. ``` -GET /api/v2/address/
[?page=&pageSize=&from=&to=&details=&contract=&secondary=usd] +GET /api/v2/address/
[?page=&pageSize=&from=&to=&details=&tokens=&contract=&secondary=usd] ``` The optional query parameters: @@ -476,9 +476,15 @@ The optional query parameters: - _txids_: _tokenBalances_ + list of txids, subject to _from_, _to_ filter and paging - _txslight_: _tokenBalances_ + list of transaction with limited details (only data from index), subject to _from_, _to_ filter and paging - _txs_: _tokenBalances_ + list of transaction with details, subject to _from_, _to_ filter and paging +- _tokens_: specifies which tokens are returned (default _nonzero_) + - _nonzero_: return only tokens with nonzero current holdings when holdings data is available + - _used_: return historically used tokens; on EVM single-address requests, fungible zero balances remain visible while empty ERC721/ERC1155 contracts stay omitted when holdings data is available + - _derived_: same as _used_ for a single-address request - _contract_: return only transactions which affect specified contract (applicable only to coins which support contracts) - _secondary_: specifies secondary (fiat) currency in which the token and total balances are returned in addition to crypto values +For _details=tokens_, current holdings are not fetched, so _tokens=nonzero_ behaves the same as _used_. + Example response for bitcoin type coin, _details_ set to _txids_ (`Address` type): diff --git a/server/public_test.go b/server/public_test.go index 12d3bf77b2..b3ef4971d4 100644 --- a/server/public_test.go +++ b/server/public_test.go @@ -23,6 +23,7 @@ import ( "github.com/martinboehm/btcutil/chaincfg" gosocketio "github.com/martinboehm/golang-socketio" "github.com/martinboehm/golang-socketio/transport" + "github.com/trezor/blockbook/api" "github.com/trezor/blockbook/bchain" "github.com/trezor/blockbook/bchain/coins/btc" "github.com/trezor/blockbook/common" @@ -214,6 +215,95 @@ func newPostRequestWithContentLength(u string, contentLength int64) *http.Reques return r } +func TestGetAddressQueryParamsParsesDocumentedAddressOptions(t *testing.T) { + s := &PublicServer{} + tests := []struct { + name string + url string + defaultDetails api.AccountDetails + wantPage int + wantPageSize int + wantDetails api.AccountDetails + wantTokensToReturn api.TokensToReturn + wantContract string + wantFromHeight uint32 + wantToHeight uint32 + wantGap int + }{ + { + name: "defaults use nonzero tokens", + url: "http://example.test/api/v2/address/0xabc", + defaultDetails: api.AccountDetailsTxidHistory, + wantPage: 0, + wantPageSize: 1000, + wantDetails: api.AccountDetailsTxidHistory, + wantTokensToReturn: api.TokensToReturnNonzeroBalance, + }, + { + name: "details tokens with used filter", + url: "http://example.test/api/v2/address/0xabc?details=tokens&tokens=used", + defaultDetails: api.AccountDetailsBasic, + wantPage: 0, + wantPageSize: 1000, + wantDetails: api.AccountDetailsTokens, + wantTokensToReturn: api.TokensToReturnUsed, + }, + { + name: "token balances with derived filter", + url: "http://example.test/api/v2/address/0xabc?details=tokenBalances&tokens=derived", + defaultDetails: api.AccountDetailsBasic, + wantPage: 0, + wantPageSize: 1000, + wantDetails: api.AccountDetailsTokenBalances, + wantTokensToReturn: api.TokensToReturnDerived, + }, + { + name: "nonzero contract filter paging and heights", + url: "http://example.test/api/v2/address/0xabc?details=txs&tokens=nonzero&contract=0xdef&page=2&pageSize=50&from=11&to=22&gap=7", + defaultDetails: api.AccountDetailsBasic, + wantPage: 2, + wantPageSize: 50, + wantDetails: api.AccountDetailsTxHistory, + wantTokensToReturn: api.TokensToReturnNonzeroBalance, + wantContract: "0xdef", + wantFromHeight: 11, + wantToHeight: 22, + wantGap: 7, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := newGetRequest(tt.url) + page, pageSize, details, filter, _, gap := s.getAddressQueryParams(r, tt.defaultDetails, 1000) + if page != tt.wantPage { + t.Fatalf("page = %d, want %d", page, tt.wantPage) + } + if pageSize != tt.wantPageSize { + t.Fatalf("pageSize = %d, want %d", pageSize, tt.wantPageSize) + } + if details != tt.wantDetails { + t.Fatalf("details = %v, want %v", details, tt.wantDetails) + } + if filter.TokensToReturn != tt.wantTokensToReturn { + t.Fatalf("tokensToReturn = %v, want %v", filter.TokensToReturn, tt.wantTokensToReturn) + } + if filter.Contract != tt.wantContract { + t.Fatalf("contract = %q, want %q", filter.Contract, tt.wantContract) + } + if filter.FromHeight != tt.wantFromHeight { + t.Fatalf("fromHeight = %d, want %d", filter.FromHeight, tt.wantFromHeight) + } + if filter.ToHeight != tt.wantToHeight { + t.Fatalf("toHeight = %d, want %d", filter.ToHeight, tt.wantToHeight) + } + if gap != tt.wantGap { + t.Fatalf("gap = %d, want %d", gap, tt.wantGap) + } + }) + } +} + func insertFiatRate(date string, rates map[string]float32, tokenRates map[string]float32, d *db.RocksDB) error { convertedDate, err := time.Parse("20060102150405", date) if err != nil { From f8e5b6680753f51503bdae2d0459da06e92040de Mon Sep 17 00:00:00 2001 From: pragmaxim Date: Tue, 7 Jul 2026 08:06:49 +0200 Subject: [PATCH 2/2] fix: check ERC721 IDs are actually nonzero in token filter hasEthereumTokenNonzeroHoldings only checked len(t.Ids) > 0, so a burned ERC721 with Ids: [0] was incorrectly treated as having nonzero holdings. --- api/worker.go | 6 ++++-- api/worker_token_filter_test.go | 7 ++++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/api/worker.go b/api/worker.go index 6c4c5cc63f..42e63da75e 100644 --- a/api/worker.go +++ b/api/worker.go @@ -1206,8 +1206,10 @@ func hasEthereumTokenNonzeroHoldings(t *Token) bool { if t.BalanceSat != nil && !IsZeroBigInt((*big.Int)(t.BalanceSat)) { return true } - if len(t.Ids) > 0 { - return true + for _, id := range t.Ids { + if !IsZeroBigInt((*big.Int)(&id)) { + return true + } } for i := range t.MultiTokenValues { if t.MultiTokenValues[i].Value == nil || !IsZeroBigInt((*big.Int)(t.MultiTokenValues[i].Value)) { diff --git a/api/worker_token_filter_test.go b/api/worker_token_filter_test.go index c61a26f79f..f2d2e7d264 100644 --- a/api/worker_token_filter_test.go +++ b/api/worker_token_filter_test.go @@ -36,8 +36,13 @@ func TestHasEthereumTokenNonzeroHoldings(t *testing.T) { want: true, }, { - name: "erc721 ids", + name: "erc721 zero ids", token: &Token{Ids: []Amount{Amount(*big.NewInt(0))}}, + want: false, + }, + { + name: "erc721 nonzero ids", + token: &Token{Ids: []Amount{Amount(*big.NewInt(1))}}, want: true, }, {