commit
fca47ac4aa
@ -0,0 +1,12 @@ |
||||
defmodule BlockScoutWeb.Resolvers.Address do |
||||
@moduledoc false |
||||
|
||||
alias Explorer.Chain |
||||
|
||||
def get_by(_, %{hashes: hashes}, _) do |
||||
case Chain.hashes_to_addresses(hashes) do |
||||
[] -> {:error, "Addresses not found."} |
||||
result -> {:ok, result} |
||||
end |
||||
end |
||||
end |
@ -0,0 +1,142 @@ |
||||
defmodule BlockScoutWeb.Schema.Query.AddressTest do |
||||
use BlockScoutWeb.ConnCase |
||||
|
||||
describe "address field" do |
||||
test "with valid argument 'hashes', returns all expected fields", %{conn: conn} do |
||||
address = insert(:address, fetched_coin_balance: 100) |
||||
|
||||
query = """ |
||||
query ($hashes: [AddressHash!]!) { |
||||
addresses(hashes: $hashes) { |
||||
hash |
||||
fetched_coin_balance |
||||
fetched_coin_balance_block_number |
||||
contract_code |
||||
} |
||||
} |
||||
""" |
||||
|
||||
variables = %{"hashes" => to_string(address.hash)} |
||||
|
||||
conn = get(conn, "/graphql", query: query, variables: variables) |
||||
|
||||
assert json_response(conn, 200) == %{ |
||||
"data" => %{ |
||||
"addresses" => [ |
||||
%{ |
||||
"hash" => to_string(address.hash), |
||||
"fetched_coin_balance" => to_string(address.fetched_coin_balance.value), |
||||
"fetched_coin_balance_block_number" => address.fetched_coin_balance_block_number, |
||||
"contract_code" => nil |
||||
} |
||||
] |
||||
} |
||||
} |
||||
end |
||||
|
||||
test "with contract address, `contract_code` is serialized as expected", %{conn: conn} do |
||||
address = insert(:contract_address, fetched_coin_balance: 100) |
||||
|
||||
query = """ |
||||
query ($hashes: [AddressHash!]!) { |
||||
addresses(hashes: $hashes) { |
||||
contract_code |
||||
} |
||||
} |
||||
""" |
||||
|
||||
variables = %{"hashes" => to_string(address.hash)} |
||||
|
||||
conn = get(conn, "/graphql", query: query, variables: variables) |
||||
|
||||
assert json_response(conn, 200) == %{ |
||||
"data" => %{ |
||||
"addresses" => [ |
||||
%{ |
||||
"contract_code" => to_string(address.contract_code) |
||||
} |
||||
] |
||||
} |
||||
} |
||||
end |
||||
|
||||
test "errors for non-existent address hashes", %{conn: conn} do |
||||
address = build(:address) |
||||
|
||||
query = """ |
||||
query ($hashes: [AddressHash!]!) { |
||||
addresses(hashes: $hashes) { |
||||
fetched_coin_balance |
||||
} |
||||
} |
||||
""" |
||||
|
||||
variables = %{"hashes" => [to_string(address.hash)]} |
||||
|
||||
conn = get(conn, "/graphql", query: query, variables: variables) |
||||
|
||||
assert %{"errors" => [error]} = json_response(conn, 200) |
||||
assert error["message"] =~ ~s(Addresses not found.) |
||||
end |
||||
|
||||
test "errors if argument 'hashes' is missing", %{conn: conn} do |
||||
query = """ |
||||
query { |
||||
addresses { |
||||
fetched_coin_balance |
||||
} |
||||
} |
||||
""" |
||||
|
||||
variables = %{} |
||||
|
||||
conn = get(conn, "/graphql", query: query, variables: variables) |
||||
|
||||
assert %{"errors" => [error]} = json_response(conn, 200) |
||||
assert error["message"] == ~s(In argument "hashes": Expected type "[AddressHash!]!", found null.) |
||||
end |
||||
|
||||
test "errors if argument 'hashes' is not a list of address hashes", %{conn: conn} do |
||||
query = """ |
||||
query ($hashes: [AddressHash!]!) { |
||||
addresses(hashes: $hashes) { |
||||
fetched_coin_balance |
||||
} |
||||
} |
||||
""" |
||||
|
||||
variables = %{"hashes" => ["someInvalidHash"]} |
||||
|
||||
conn = get(conn, "/graphql", query: query, variables: variables) |
||||
|
||||
assert %{"errors" => [error]} = json_response(conn, 200) |
||||
assert error["message"] =~ ~s(Argument "hashes" has invalid value) |
||||
end |
||||
|
||||
test "correlates complexity to size of 'hashes' argument", %{conn: conn} do |
||||
# max of 12 addresses with four fields of complexity 1 can be fetched |
||||
# per query: |
||||
# 12 * 4 = 48, which is less than a max complexity of 50 |
||||
hashes = 13 |> build_list(:address) |> Enum.map(&to_string(&1.hash)) |
||||
|
||||
query = """ |
||||
query ($hashes: [AddressHash!]!) { |
||||
addresses(hashes: $hashes) { |
||||
hash |
||||
fetched_coin_balance |
||||
fetched_coin_balance_block_number |
||||
contract_code |
||||
} |
||||
} |
||||
""" |
||||
|
||||
variables = %{"hashes" => hashes} |
||||
|
||||
conn = get(conn, "/graphql", query: query, variables: variables) |
||||
|
||||
assert %{"errors" => [error1, error2]} = json_response(conn, 200) |
||||
assert error1["message"] =~ ~s(Field addresses is too complex) |
||||
assert error2["message"] =~ ~s(Operation is too complex) |
||||
end |
||||
end |
||||
end |
@ -0,0 +1,108 @@ |
||||
defmodule Explorer.Chain.Address.CurrentTokenBalance do |
||||
@moduledoc """ |
||||
Represents the current token balance from addresses according to the last block. |
||||
""" |
||||
|
||||
use Ecto.Schema |
||||
import Ecto.Changeset |
||||
import Ecto.Query, only: [from: 2, limit: 2, order_by: 3, preload: 2, where: 3] |
||||
|
||||
alias Explorer.{Chain, PagingOptions} |
||||
alias Explorer.Chain.{Address, Block, Hash, Token} |
||||
|
||||
@default_paging_options %PagingOptions{page_size: 50} |
||||
|
||||
@typedoc """ |
||||
* `address` - The `t:Explorer.Chain.Address.t/0` that is the balance's owner. |
||||
* `address_hash` - The address hash foreign key. |
||||
* `token` - The `t:Explorer.Chain.Token/0` so that the address has the balance. |
||||
* `token_contract_address_hash` - The contract address hash foreign key. |
||||
* `block_number` - The block's number that the transfer took place. |
||||
* `value` - The value that's represents the balance. |
||||
""" |
||||
@type t :: %__MODULE__{ |
||||
address: %Ecto.Association.NotLoaded{} | Address.t(), |
||||
address_hash: Hash.Address.t(), |
||||
token: %Ecto.Association.NotLoaded{} | Token.t(), |
||||
token_contract_address_hash: Hash.Address, |
||||
block_number: Block.block_number(), |
||||
inserted_at: DateTime.t(), |
||||
updated_at: DateTime.t(), |
||||
value: Decimal.t() | nil |
||||
} |
||||
|
||||
schema "address_current_token_balances" do |
||||
field(:value, :decimal) |
||||
field(:block_number, :integer) |
||||
field(:value_fetched_at, :utc_datetime) |
||||
|
||||
belongs_to(:address, Address, foreign_key: :address_hash, references: :hash, type: Hash.Address) |
||||
|
||||
belongs_to( |
||||
:token, |
||||
Token, |
||||
foreign_key: :token_contract_address_hash, |
||||
references: :contract_address_hash, |
||||
type: Hash.Address |
||||
) |
||||
|
||||
timestamps() |
||||
end |
||||
|
||||
@optional_fields ~w(value value_fetched_at)a |
||||
@required_fields ~w(address_hash block_number token_contract_address_hash)a |
||||
@allowed_fields @optional_fields ++ @required_fields |
||||
|
||||
@doc false |
||||
def changeset(%__MODULE__{} = token_balance, attrs) do |
||||
token_balance |
||||
|> cast(attrs, @allowed_fields) |
||||
|> validate_required(@required_fields) |
||||
|> foreign_key_constraint(:address_hash) |
||||
|> foreign_key_constraint(:token_contract_address_hash) |
||||
end |
||||
|
||||
{:ok, burn_address_hash} = Chain.string_to_address_hash("0x0000000000000000000000000000000000000000") |
||||
@burn_address_hash burn_address_hash |
||||
|
||||
@doc """ |
||||
Builds an `Ecto.Query` to fetch the token holders from the given token contract address hash. |
||||
|
||||
The Token Holders are the addresses that own a positive amount of the Token. So this query is |
||||
considering the following conditions: |
||||
|
||||
* The token balance from the last block. |
||||
* Balances greater than 0. |
||||
* Excluding the burn address (0x0000000000000000000000000000000000000000). |
||||
|
||||
""" |
||||
def token_holders_ordered_by_value(token_contract_address_hash, options \\ []) do |
||||
paging_options = Keyword.get(options, :paging_options, @default_paging_options) |
||||
|
||||
token_contract_address_hash |
||||
|> token_holders_query |
||||
|> preload(:address) |
||||
|> order_by([tb], desc: :value) |
||||
|> page_token_balances(paging_options) |
||||
|> limit(^paging_options.page_size) |
||||
end |
||||
|
||||
defp token_holders_query(token_contract_address_hash) do |
||||
from( |
||||
tb in __MODULE__, |
||||
where: tb.token_contract_address_hash == ^token_contract_address_hash, |
||||
where: tb.address_hash != ^@burn_address_hash, |
||||
where: tb.value > 0 |
||||
) |
||||
end |
||||
|
||||
defp page_token_balances(query, %PagingOptions{key: nil}), do: query |
||||
|
||||
defp page_token_balances(query, %PagingOptions{key: {value, address_hash}}) do |
||||
where( |
||||
query, |
||||
[tb], |
||||
tb.value < ^value or (tb.value == ^value and tb.address_hash < ^address_hash) |
||||
) |
||||
end |
||||
end |
@ -0,0 +1,124 @@ |
||||
defmodule Explorer.Chain.Import.Address.CurrentTokenBalances do |
||||
@moduledoc """ |
||||
Bulk imports `t:Explorer.Chain.Address.CurrentTokenBalance.t/0`. |
||||
""" |
||||
|
||||
require Ecto.Query |
||||
|
||||
import Ecto.Query, only: [from: 2] |
||||
|
||||
alias Ecto.{Changeset, Multi} |
||||
alias Explorer.Chain.Address.CurrentTokenBalance |
||||
alias Explorer.Chain.Import |
||||
|
||||
@behaviour Import.Runner |
||||
|
||||
# milliseconds |
||||
@timeout 60_000 |
||||
|
||||
@type imported :: [CurrentTokenBalance.t()] |
||||
|
||||
@impl Import.Runner |
||||
def ecto_schema_module, do: CurrentTokenBalance |
||||
|
||||
@impl Import.Runner |
||||
def option_key, do: :address_current_token_balances |
||||
|
||||
@impl Import.Runner |
||||
def imported_table_row do |
||||
%{ |
||||
value_type: "[#{ecto_schema_module()}.t()]", |
||||
value_description: "List of `t:#{ecto_schema_module()}.t/0`s" |
||||
} |
||||
end |
||||
|
||||
@impl Import.Runner |
||||
def run(multi, changes_list, %{timestamps: timestamps} = options) do |
||||
insert_options = |
||||
options |
||||
|> Map.get(option_key(), %{}) |
||||
|> Map.take(~w(on_conflict timeout)a) |
||||
|> Map.put_new(:timeout, @timeout) |
||||
|> Map.put(:timestamps, timestamps) |
||||
|
||||
Multi.run(multi, :address_current_token_balances, fn _ -> |
||||
insert(changes_list, insert_options) |
||||
end) |
||||
end |
||||
|
||||
@impl Import.Runner |
||||
def timeout, do: @timeout |
||||
|
||||
@spec insert([map()], %{ |
||||
optional(:on_conflict) => Import.Runner.on_conflict(), |
||||
required(:timeout) => timeout(), |
||||
required(:timestamps) => Import.timestamps() |
||||
}) :: |
||||
{:ok, [CurrentTokenBalance.t()]} |
||||
| {:error, [Changeset.t()]} |
||||
def insert(changes_list, %{timeout: timeout, timestamps: timestamps} = options) when is_list(changes_list) do |
||||
on_conflict = Map.get_lazy(options, :on_conflict, &default_on_conflict/0) |
||||
|
||||
{:ok, _} = |
||||
Import.insert_changes_list( |
||||
unique_token_balances(changes_list), |
||||
conflict_target: ~w(address_hash token_contract_address_hash)a, |
||||
on_conflict: on_conflict, |
||||
for: CurrentTokenBalance, |
||||
returning: true, |
||||
timeout: timeout, |
||||
timestamps: timestamps |
||||
) |
||||
end |
||||
|
||||
# Remove duplicated token balances based on `{address_hash, token_hash}` considering the last block |
||||
# to avoid `cardinality_violation` error in Postgres. This error happens when there are duplicated |
||||
# rows being inserted. |
||||
defp unique_token_balances(changes_list) do |
||||
changes_list |
||||
|> Enum.sort(&(&1.block_number > &2.block_number)) |
||||
|> Enum.uniq_by(fn %{address_hash: address_hash, token_contract_address_hash: token_hash} -> |
||||
{address_hash, token_hash} |
||||
end) |
||||
end |
||||
|
||||
defp default_on_conflict do |
||||
from( |
||||
current_token_balance in CurrentTokenBalance, |
||||
update: [ |
||||
set: [ |
||||
block_number: |
||||
fragment( |
||||
"CASE WHEN EXCLUDED.block_number > ? THEN EXCLUDED.block_number ELSE ? END", |
||||
current_token_balance.block_number, |
||||
current_token_balance.block_number |
||||
), |
||||
inserted_at: |
||||
fragment( |
||||
"CASE WHEN EXCLUDED.block_number > ? THEN EXCLUDED.inserted_at ELSE ? END", |
||||
current_token_balance.block_number, |
||||
current_token_balance.inserted_at |
||||
), |
||||
updated_at: |
||||
fragment( |
||||
"CASE WHEN EXCLUDED.block_number > ? THEN EXCLUDED.updated_at ELSE ? END", |
||||
current_token_balance.block_number, |
||||
current_token_balance.updated_at |
||||
), |
||||
value: |
||||
fragment( |
||||
"CASE WHEN EXCLUDED.block_number > ? THEN EXCLUDED.value ELSE ? END", |
||||
current_token_balance.block_number, |
||||
current_token_balance.value |
||||
), |
||||
value_fetched_at: |
||||
fragment( |
||||
"CASE WHEN EXCLUDED.block_number > ? THEN EXCLUDED.value_fetched_at ELSE ? END", |
||||
current_token_balance.block_number, |
||||
current_token_balance.value_fetched_at |
||||
) |
||||
] |
||||
] |
||||
) |
||||
end |
||||
end |
@ -0,0 +1,32 @@ |
||||
defmodule Explorer.Repo.Migrations.CreateAddressCurrentTokenBalances do |
||||
use Ecto.Migration |
||||
|
||||
def change do |
||||
create table(:address_current_token_balances) do |
||||
add(:address_hash, references(:addresses, column: :hash, type: :bytea), null: false) |
||||
add(:block_number, :bigint, null: false) |
||||
|
||||
add( |
||||
:token_contract_address_hash, |
||||
references(:tokens, column: :contract_address_hash, type: :bytea), |
||||
null: false |
||||
) |
||||
|
||||
add(:value, :decimal, null: true) |
||||
add(:value_fetched_at, :utc_datetime, default: fragment("NULL"), null: true) |
||||
|
||||
timestamps(null: false, type: :utc_datetime) |
||||
end |
||||
|
||||
create(unique_index(:address_current_token_balances, ~w(address_hash token_contract_address_hash)a)) |
||||
|
||||
create( |
||||
index( |
||||
:address_current_token_balances, |
||||
[:value], |
||||
name: :address_current_token_balances_value, |
||||
where: "value IS NOT NULL" |
||||
) |
||||
) |
||||
end |
||||
end |
@ -0,0 +1,149 @@ |
||||
defmodule Explorer.Chain.Address.CurrentTokenBalanceTest do |
||||
use Explorer.DataCase |
||||
|
||||
alias Explorer.{Chain, PagingOptions, Repo} |
||||
alias Explorer.Chain.Token |
||||
alias Explorer.Chain.Address.CurrentTokenBalance |
||||
|
||||
describe "token_holders_ordered_by_value/2" do |
||||
test "returns the last value for each address" do |
||||
%Token{contract_address_hash: contract_address_hash} = insert(:token) |
||||
address_a = insert(:address) |
||||
address_b = insert(:address) |
||||
|
||||
insert( |
||||
:address_current_token_balance, |
||||
address: address_a, |
||||
token_contract_address_hash: contract_address_hash, |
||||
value: 5000 |
||||
) |
||||
|
||||
insert( |
||||
:address_current_token_balance, |
||||
address: address_b, |
||||
block_number: 1001, |
||||
token_contract_address_hash: contract_address_hash, |
||||
value: 4000 |
||||
) |
||||
|
||||
token_holders_count = |
||||
contract_address_hash |
||||
|> CurrentTokenBalance.token_holders_ordered_by_value() |
||||
|> Repo.all() |
||||
|> Enum.count() |
||||
|
||||
assert token_holders_count == 2 |
||||
end |
||||
|
||||
test "sort by the highest value" do |
||||
%Token{contract_address_hash: contract_address_hash} = insert(:token) |
||||
address_a = insert(:address) |
||||
address_b = insert(:address) |
||||
address_c = insert(:address) |
||||
|
||||
insert( |
||||
:address_current_token_balance, |
||||
address: address_a, |
||||
token_contract_address_hash: contract_address_hash, |
||||
value: 5000 |
||||
) |
||||
|
||||
insert( |
||||
:address_current_token_balance, |
||||
address: address_b, |
||||
token_contract_address_hash: contract_address_hash, |
||||
value: 4000 |
||||
) |
||||
|
||||
insert( |
||||
:address_current_token_balance, |
||||
address: address_c, |
||||
token_contract_address_hash: contract_address_hash, |
||||
value: 15000 |
||||
) |
||||
|
||||
token_holders_values = |
||||
contract_address_hash |
||||
|> CurrentTokenBalance.token_holders_ordered_by_value() |
||||
|> Repo.all() |
||||
|> Enum.map(&Decimal.to_integer(&1.value)) |
||||
|
||||
assert token_holders_values == [15_000, 5_000, 4_000] |
||||
end |
||||
|
||||
test "returns only token balances that have value greater than 0" do |
||||
%Token{contract_address_hash: contract_address_hash} = insert(:token) |
||||
|
||||
insert( |
||||
:address_current_token_balance, |
||||
token_contract_address_hash: contract_address_hash, |
||||
value: 0 |
||||
) |
||||
|
||||
result = |
||||
contract_address_hash |
||||
|> CurrentTokenBalance.token_holders_ordered_by_value() |
||||
|> Repo.all() |
||||
|
||||
assert result == [] |
||||
end |
||||
|
||||
test "ignores the burn address" do |
||||
{:ok, burn_address_hash} = Chain.string_to_address_hash("0x0000000000000000000000000000000000000000") |
||||
|
||||
burn_address = insert(:address, hash: burn_address_hash) |
||||
|
||||
%Token{contract_address_hash: contract_address_hash} = insert(:token) |
||||
|
||||
insert( |
||||
:address_current_token_balance, |
||||
address: burn_address, |
||||
token_contract_address_hash: contract_address_hash, |
||||
value: 1000 |
||||
) |
||||
|
||||
result = |
||||
contract_address_hash |
||||
|> CurrentTokenBalance.token_holders_ordered_by_value() |
||||
|> Repo.all() |
||||
|
||||
assert result == [] |
||||
end |
||||
|
||||
test "paginates the result by value and different address" do |
||||
address_a = build(:address, hash: "0xcb2cf1fd3199584ac5faa16c6aca49472dc6495a") |
||||
address_b = build(:address, hash: "0x5f26097334b6a32b7951df61fd0c5803ec5d8354") |
||||
|
||||
%Token{contract_address_hash: contract_address_hash} = insert(:token) |
||||
|
||||
first_page = |
||||
insert( |
||||
:address_current_token_balance, |
||||
address: address_a, |
||||
token_contract_address_hash: contract_address_hash, |
||||
value: 4000 |
||||
) |
||||
|
||||
second_page = |
||||
insert( |
||||
:address_current_token_balance, |
||||
address: address_b, |
||||
token_contract_address_hash: contract_address_hash, |
||||
value: 4000 |
||||
) |
||||
|
||||
paging_options = %PagingOptions{ |
||||
key: {first_page.value, first_page.address_hash}, |
||||
page_size: 2 |
||||
} |
||||
|
||||
result_paginated = |
||||
contract_address_hash |
||||
|> CurrentTokenBalance.token_holders_ordered_by_value(paging_options: paging_options) |
||||
|> Repo.all() |
||||
|> Enum.map(& &1.address_hash) |
||||
|
||||
assert result_paginated == [second_page.address_hash] |
||||
end |
||||
end |
||||
end |
@ -0,0 +1,95 @@ |
||||
defmodule Explorer.Chain.Import.Address.CurrentTokenBalancesTest do |
||||
use Explorer.DataCase |
||||
|
||||
alias Explorer.Chain.Import.Address.CurrentTokenBalances |
||||
|
||||
alias Explorer.Chain.{Address.CurrentTokenBalance} |
||||
|
||||
describe "insert/2" do |
||||
setup do |
||||
address = insert(:address, hash: "0xe8ddc5c7a2d2f0d7a9798459c0104fdf5e987aca") |
||||
token = insert(:token) |
||||
|
||||
insert_options = %{ |
||||
timeout: :infinity, |
||||
timestamps: %{inserted_at: DateTime.utc_now(), updated_at: DateTime.utc_now()} |
||||
} |
||||
|
||||
%{address: address, token: token, insert_options: insert_options} |
||||
end |
||||
|
||||
test "inserts in the current token balances", %{address: address, token: token, insert_options: insert_options} do |
||||
changes = [ |
||||
%{ |
||||
address_hash: address.hash, |
||||
block_number: 1, |
||||
token_contract_address_hash: token.contract_address_hash, |
||||
value: Decimal.new(100) |
||||
} |
||||
] |
||||
|
||||
CurrentTokenBalances.insert(changes, insert_options) |
||||
|
||||
current_token_balances = |
||||
CurrentTokenBalance |
||||
|> Explorer.Repo.all() |
||||
|> Enum.count() |
||||
|
||||
assert current_token_balances == 1 |
||||
end |
||||
|
||||
test "considers the last block upserting", %{address: address, token: token, insert_options: insert_options} do |
||||
insert( |
||||
:address_current_token_balance, |
||||
address: address, |
||||
block_number: 1, |
||||
token_contract_address_hash: token.contract_address_hash, |
||||
value: 100 |
||||
) |
||||
|
||||
changes = [ |
||||
%{ |
||||
address_hash: address.hash, |
||||
block_number: 2, |
||||
token_contract_address_hash: token.contract_address_hash, |
||||
value: Decimal.new(200) |
||||
} |
||||
] |
||||
|
||||
CurrentTokenBalances.insert(changes, insert_options) |
||||
|
||||
current_token_balance = Explorer.Repo.get_by(CurrentTokenBalance, address_hash: address.hash) |
||||
|
||||
assert current_token_balance.block_number == 2 |
||||
assert current_token_balance.value == Decimal.new(200) |
||||
end |
||||
|
||||
test "considers the last block when there are duplicated params", %{ |
||||
address: address, |
||||
token: token, |
||||
insert_options: insert_options |
||||
} do |
||||
changes = [ |
||||
%{ |
||||
address_hash: address.hash, |
||||
block_number: 4, |
||||
token_contract_address_hash: token.contract_address_hash, |
||||
value: Decimal.new(200) |
||||
}, |
||||
%{ |
||||
address_hash: address.hash, |
||||
block_number: 1, |
||||
token_contract_address_hash: token.contract_address_hash, |
||||
value: Decimal.new(100) |
||||
} |
||||
] |
||||
|
||||
CurrentTokenBalances.insert(changes, insert_options) |
||||
|
||||
current_token_balance = Explorer.Repo.get_by(CurrentTokenBalance, address_hash: address.hash) |
||||
|
||||
assert current_token_balance.block_number == 4 |
||||
assert current_token_balance.value == Decimal.new(200) |
||||
end |
||||
end |
||||
end |
Loading…
Reference in new issue