Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 66 additions & 18 deletions api/worker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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
}
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
167 changes: 163 additions & 4 deletions api/worker_token_filter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand All @@ -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,
},
Expand All @@ -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)
}
})
}
}
8 changes: 7 additions & 1 deletion docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<address>[?page=<page>&pageSize=<size>&from=<block height>&to=<block height>&details=<basic|tokens|tokenBalances|txids|txs>&contract=<contract address>&secondary=usd]
GET /api/v2/address/<address>[?page=<page>&pageSize=<size>&from=<block height>&to=<block height>&details=<basic|tokens|tokenBalances|txids|txs>&tokens=<nonzero|used|derived>&contract=<contract address>&secondary=usd]
```

The optional query parameters:
Expand All @@ -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):

<!-- https://btc1.trezor.io/api/v2/address/bc1q0wd209cv5k9pd9mhk7nspacywcj038xxdhnt5u?details=txids -->
Expand Down
Loading
Loading