@ -1,3 +1,3 @@ |
||||
elixir 1.7.1 |
||||
erlang 21.0.4 |
||||
nodejs 10.5.0 |
||||
nodejs 10.11.0 |
||||
|
@ -0,0 +1,51 @@ |
||||
import $ from 'jquery' |
||||
|
||||
const stringContains = (query, string) => { |
||||
return string.toLowerCase().search(query) === -1 |
||||
} |
||||
|
||||
const hideUnmatchedToken = (query, token) => { |
||||
const $token = $(token) |
||||
const tokenName = $token.data('token-name') |
||||
const tokenSymbol = $token.data('token-symbol') |
||||
|
||||
if (stringContains(query, tokenName) && stringContains(query, tokenSymbol)) { |
||||
$token.addClass('d-none') |
||||
} else { |
||||
$token.removeClass('d-none') |
||||
} |
||||
} |
||||
|
||||
const hideEmptyType = (container) => { |
||||
const $container = $(container) |
||||
const type = $container.data('token-type') |
||||
const countVisibleTokens = $container.children('[data-token-name]:not(.d-none)').length |
||||
|
||||
if (countVisibleTokens === 0) { |
||||
$container.addClass('d-none') |
||||
} else { |
||||
$(`[data-number-of-tokens-by-type='${type}']`).empty().append(countVisibleTokens) |
||||
$container.removeClass('d-none') |
||||
} |
||||
} |
||||
|
||||
const TokenBalanceDropdownSearch = (element, event) => { |
||||
const $element = $(element) |
||||
const $tokensCount = $element.find('[data-tokens-count]') |
||||
const $tokens = $element.find('[data-token-name]') |
||||
const $tokenTypes = $element.find('[data-token-type]') |
||||
const query = event.target.value.toLowerCase() |
||||
|
||||
$tokens.each((_index, token) => hideUnmatchedToken(query, token)) |
||||
$tokenTypes.each((_index, container) => hideEmptyType(container)) |
||||
|
||||
$tokensCount.html($tokensCount.html().replace(/\d+/g, $tokens.not('.d-none').length)) |
||||
} |
||||
|
||||
$('[data-token-balance-dropdown]').on('hidden.bs.dropdown', _event => { |
||||
$('[data-filter-dropdown-tokens]').val('').trigger('input') |
||||
}) |
||||
|
||||
$('[data-token-balance-dropdown]').on('input', function (event) { |
||||
TokenBalanceDropdownSearch(this, event) |
||||
}) |
After Width: | Height: | Size: 17 KiB |
After Width: | Height: | Size: 42 KiB |
After Width: | Height: | Size: 16 KiB |
@ -0,0 +1,9 @@ |
||||
<?xml version="1.0" encoding="utf-8"?> |
||||
<browserconfig> |
||||
<msapplication> |
||||
<tile> |
||||
<square150x150logo src="/mstile-150x150.png"/> |
||||
<TileColor>#da532c</TileColor> |
||||
</tile> |
||||
</msapplication> |
||||
</browserconfig> |
After Width: | Height: | Size: 20 KiB |
After Width: | Height: | Size: 20 KiB |
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 15 KiB |
After Width: | Height: | Size: 10 KiB |
After Width: | Height: | Size: 2.4 KiB |
@ -0,0 +1,19 @@ |
||||
{ |
||||
"name": "", |
||||
"short_name": "", |
||||
"icons": [ |
||||
{ |
||||
"src": "/android-chrome-192x192.png", |
||||
"sizes": "192x192", |
||||
"type": "image/png" |
||||
}, |
||||
{ |
||||
"src": "/android-chrome-512x512.png", |
||||
"sizes": "512x512", |
||||
"type": "image/png" |
||||
} |
||||
], |
||||
"theme_color": "#ffffff", |
||||
"background_color": "#ffffff", |
||||
"display": "standalone" |
||||
} |
@ -1,16 +1,24 @@ |
||||
<h6 class="dropdown-header"> |
||||
<%= @type %> (<%= Enum.count(@token_balances)%>) |
||||
</h6> |
||||
<%= for token_balance <- sort_by_name(@token_balances) do %> |
||||
<div class="border-bottom"> |
||||
<%= link( |
||||
to: token_path(@conn, :show, token_balance.token.contract_address_hash), |
||||
class: "dropdown-item" |
||||
) do %> |
||||
<p class="mb-0"><%= token_name(token_balance.token) %></p> |
||||
<p class="mb-0"> |
||||
<%= format_according_to_decimals(token_balance.value, token_balance.token.decimals) %> <%= token_balance.token.symbol %> |
||||
</p> |
||||
<% end %> |
||||
</div> |
||||
<% end %> |
||||
<div data-token-type="<%= @type %>"> |
||||
<h6 class="dropdown-header"> |
||||
<%= @type %> (<span data-number-of-tokens-by-type="<%= @type %>"><%= Enum.count(@token_balances)%></span>) |
||||
</h6> |
||||
|
||||
<%= for token_balance <- sort_by_name(@token_balances) do %> |
||||
<div |
||||
class="border-bottom" |
||||
data-dropdown-token-balance-test |
||||
data-token-name="<%= token_name(token_balance.token) %>" |
||||
data-token-symbol="<%= token_balance.token.symbol %>" |
||||
> |
||||
<%= link( |
||||
to: token_path(@conn, :show, token_balance.token.contract_address_hash), |
||||
class: "dropdown-item" |
||||
) do %> |
||||
<p class="mb-0"><%= token_name(token_balance.token) %></p> |
||||
<p class="mb-0"> |
||||
<%= format_according_to_decimals(token_balance.value, token_balance.token.decimals) %> <%= token_balance.token.symbol %> |
||||
</p> |
||||
<% end %> |
||||
</div> |
||||
<% end %> |
||||
</div> |
||||
|
@ -0,0 +1,24 @@ |
||||
<div class="tile tile-type-<%= BlockScoutWeb.TransactionView.type_suffix(@transaction) %> fade-in" data-test="<%= BlockScoutWeb.TransactionView.type_suffix(@transaction) %>" data-transaction-hash="<%= @transaction.hash %>"> |
||||
<div class="row" data-test="chain_transaction"> |
||||
<div class="col-md-2 d-flex flex-row flex-md-column align-items-left justify-content-start justify-content-lg-center mb-1 mb-md-0 pl-md-4"> |
||||
<span class="tile-label" data-test="transaction_type"> <%= BlockScoutWeb.TransactionView.transaction_display_type(@transaction) %></span> |
||||
<div class="tile-status-label ml-2 ml-md-0" data-test="transaction_status"><%= BlockScoutWeb.TransactionView.formatted_status(@transaction) %></div> |
||||
</div> |
||||
<div class="col-md-7 col-lg-8 d-flex flex-column pr-2 pr-sm-2 pr-md-0"> |
||||
<%= render BlockScoutWeb.TransactionView, "_link.html", transaction_hash: @transaction.hash %> |
||||
<span class="text-nowrap"> |
||||
<%= render BlockScoutWeb.AddressView, "_link.html", address: @transaction.from_address, contract: BlockScoutWeb.AddressView.contract?(@transaction.from_address) %> |
||||
→ |
||||
<%= if @transaction.to_address_hash do %> |
||||
<%= render BlockScoutWeb.AddressView, "_link.html", address: @transaction.to_address, contract: BlockScoutWeb.AddressView.contract?(@transaction.to_address) %> |
||||
<% else %> |
||||
<%= gettext("Contract Address Pending") %> |
||||
<% end %> |
||||
</span> |
||||
<span class="d-flex flex-md-row flex-column mt-3 mt-md-0"> |
||||
<span class="tile-title"><%= BlockScoutWeb.TransactionView.value(@transaction, include_label: false) %> <%= gettext "Ether" %></span> |
||||
<span class="ml-0 ml-md-1 text-nowrap"> <%= BlockScoutWeb.TransactionView.formatted_fee(@transaction, denomination: :ether, include_label: false) %> <%= gettext "TX Fee" %></span> |
||||
</span> |
||||
</div> |
||||
</div> |
||||
</div> |
@ -1,10 +1,5 @@ |
||||
defmodule BlockScoutWeb.AddressContractView do |
||||
use BlockScoutWeb, :view |
||||
|
||||
import BlockScoutWeb.AddressView, only: [smart_contract_verified?: 1, smart_contract_with_read_only_functions?: 1] |
||||
|
||||
def format_smart_contract_abi(abi), do: Poison.encode!(abi, pretty: false) |
||||
|
||||
def format_optimization(true), do: gettext("true") |
||||
def format_optimization(false), do: gettext("false") |
||||
end |
||||
|
@ -1,3 +1,9 @@ |
||||
defmodule BlockScoutWeb.LayoutViewTest do |
||||
use BlockScoutWeb.ConnCase, async: true |
||||
|
||||
alias BlockScoutWeb.LayoutView |
||||
|
||||
test "configured_social_media_services/0" do |
||||
assert length(LayoutView.configured_social_media_services()) > 0 |
||||
end |
||||
end |
||||
|
@ -0,0 +1,17 @@ |
||||
defmodule Phoenix.Param.Explorer.Chain.BlockTest do |
||||
use ExUnit.Case |
||||
|
||||
import Explorer.Factory |
||||
|
||||
test "without consensus" do |
||||
block = build(:block, consensus: false) |
||||
|
||||
assert Phoenix.Param.to_param(block) == to_string(block.hash) |
||||
end |
||||
|
||||
test "with consensus" do |
||||
block = build(:block, consensus: true) |
||||
|
||||
assert Phoenix.Param.to_param(block) == to_string(block.number) |
||||
end |
||||
end |
@ -0,0 +1,39 @@ |
||||
defmodule EthereumJSONRPC.Uncle do |
||||
@moduledoc """ |
||||
[Uncle](https://github.com/ethereum/wiki/wiki/Glossary#ethereum-blockchain). |
||||
|
||||
An uncle is a block that didn't make the main chain due to them being validated slightly behind what became the main |
||||
chain. |
||||
""" |
||||
|
||||
@type elixir :: %{String.t() => EthereumJSONRPC.hash()} |
||||
|
||||
@typedoc """ |
||||
* `"hash"` - the hash of the uncle block. |
||||
* `"nephewHash"` - the hash of the nephew block that included `"hash` as an uncle. |
||||
""" |
||||
@type t :: %{String.t() => EthereumJSONRPC.hash()} |
||||
|
||||
@type params :: %{nephew_hash: EthereumJSONRPC.hash(), uncle_hash: EthereumJSONRPC.hash()} |
||||
|
||||
@doc """ |
||||
Converts each entry in `t:elixir/0` to `t:params/0` used in `Explorer.Chain.Uncle.changeset/2`. |
||||
|
||||
iex> EthereumJSONRPC.Uncle.elixir_to_params( |
||||
...> %{ |
||||
...> "hash" => "0xe670ec64341771606e55d6b4ca35a1a6b75ee3d5145a99d05921026d15273311", |
||||
...> "nephewHash" => "0xe52d77084cab13a4e724162bcd8c6028e5ecfaa04d091ee476e96b9958ed6b47" |
||||
...> } |
||||
...> ) |
||||
%{ |
||||
nephew_hash: "0xe52d77084cab13a4e724162bcd8c6028e5ecfaa04d091ee476e96b9958ed6b47", |
||||
uncle_hash: "0xe670ec64341771606e55d6b4ca35a1a6b75ee3d5145a99d05921026d15273311" |
||||
} |
||||
|
||||
""" |
||||
@spec elixir_to_params(elixir) :: params |
||||
def elixir_to_params(%{"hash" => uncle_hash, "nephewHash" => nephew_hash}) |
||||
when is_binary(uncle_hash) and is_binary(nephew_hash) do |
||||
%{nephew_hash: nephew_hash, uncle_hash: uncle_hash} |
||||
end |
||||
end |
@ -0,0 +1,35 @@ |
||||
defmodule EthereumJSONRPC.Uncles do |
||||
@moduledoc """ |
||||
List of [uncles](https://github.com/ethereum/wiki/wiki/Glossary#ethereum-blockchain). Uncles are blocks that didn't |
||||
make the main chain due to them being validated slightly behind what became the main chain. |
||||
""" |
||||
|
||||
alias EthereumJSONRPC.Uncle |
||||
|
||||
@type elixir :: [Uncle.elixir()] |
||||
@type params :: [Uncle.params()] |
||||
|
||||
@doc """ |
||||
Converts each entry in `elixir` to params used in `Explorer.Chain.Uncle.changeset/2`. |
||||
|
||||
iex> EthereumJSONRPC.Uncles.elixir_to_params( |
||||
...> [ |
||||
...> %{ |
||||
...> "hash" => "0xe670ec64341771606e55d6b4ca35a1a6b75ee3d5145a99d05921026d15273311", |
||||
...> "nephewHash" => "0xe52d77084cab13a4e724162bcd8c6028e5ecfaa04d091ee476e96b9958ed6b47" |
||||
...> } |
||||
...> ] |
||||
...> ) |
||||
[ |
||||
%{ |
||||
uncle_hash: "0xe670ec64341771606e55d6b4ca35a1a6b75ee3d5145a99d05921026d15273311", |
||||
nephew_hash: "0xe52d77084cab13a4e724162bcd8c6028e5ecfaa04d091ee476e96b9958ed6b47" |
||||
} |
||||
] |
||||
|
||||
""" |
||||
@spec elixir_to_params(elixir) :: params |
||||
def elixir_to_params(elixir) when is_list(elixir) do |
||||
Enum.map(elixir, &Uncle.elixir_to_params/1) |
||||
end |
||||
end |
@ -0,0 +1,5 @@ |
||||
defmodule EthereumJSONRPC.UncleTest do |
||||
use ExUnit.Case, async: true |
||||
|
||||
doctest EthereumJSONRPC.Uncle |
||||
end |
@ -0,0 +1,5 @@ |
||||
defmodule EthereumJSONRPC.UnclesTest do |
||||
use ExUnit.Case, async: true |
||||
|
||||
doctest EthereumJSONRPC.Uncles |
||||
end |
@ -0,0 +1,64 @@ |
||||
defmodule Explorer.Chain.Block.SecondDegreeRelation do |
||||
@moduledoc """ |
||||
A [second-degree relative](https://en.wikipedia.org/wiki/Second-degree_relative) is a relative where the share |
||||
point is the parent's parent block in the chain. |
||||
|
||||
For Ethereum, nephews are rewarded for included their uncles. |
||||
|
||||
Uncles occur when a Proof-of-Work proof is completed slightly late, but before the next block is completes, so the |
||||
network knows about the late proof and can credit as an uncle in the next block. |
||||
|
||||
This schema is the join schema between the `nephew` and the `uncle` it is is including the `uncle`. The actual |
||||
`uncle` block is still a normal `t:Explorer.Chain.Block.t/0`. |
||||
""" |
||||
|
||||
use Explorer.Schema |
||||
|
||||
alias Explorer.Chain.{Block, Hash} |
||||
|
||||
@optional_fields ~w(uncle_fetched_at)a |
||||
@required_fields ~w(nephew_hash uncle_hash)a |
||||
@allowed_fields @optional_fields ++ @required_fields |
||||
|
||||
@typedoc """ |
||||
* `nephew` - `t:Explorer.Chain.Block.t/0` that included `hash` as an uncle. |
||||
* `nephew_hash` - foreign key for `nephew_block`. |
||||
* `uncle` - the uncle block. Maybe `nil` when `uncle_fetched_at` is `nil`. It could not be `nil` if the |
||||
`uncle_hash` was fetched for some other reason already. |
||||
* `uncle_fetched_at` - when `t:Explorer.Chain.Block.t/0` for `uncle_hash` was confirmed as fetched. |
||||
* `uncle_hash` - foreign key for `uncle`. |
||||
""" |
||||
@type t :: |
||||
%__MODULE__{ |
||||
nephew: %Ecto.Association.NotLoaded{} | Block.t(), |
||||
nephew_hash: Hash.Full.t(), |
||||
uncle: %Ecto.Association.NotLoaded{} | Block.t() | nil, |
||||
uncle_fetched_at: nil, |
||||
uncle_hash: Hash.Full.t() |
||||
} |
||||
| %__MODULE__{ |
||||
nephew: %Ecto.Association.NotLoaded{} | Block.t(), |
||||
nephew_hash: Hash.Full.t(), |
||||
uncle: %Ecto.Association.NotLoaded{} | Block.t(), |
||||
uncle_fetched_at: DateTime.t(), |
||||
uncle_hash: Hash.Full.t() |
||||
} |
||||
|
||||
@primary_key false |
||||
schema "block_second_degree_relations" do |
||||
field(:uncle_fetched_at, :utc_datetime) |
||||
|
||||
belongs_to(:nephew, Block, foreign_key: :nephew_hash, references: :hash, type: Hash.Full) |
||||
belongs_to(:uncle, Block, foreign_key: :uncle_hash, references: :hash, type: Hash.Full) |
||||
end |
||||
|
||||
def changeset(%__MODULE__{} = uncle, params) do |
||||
uncle |
||||
|> cast(params, @allowed_fields) |
||||
|> validate_required(@required_fields) |
||||
|> foreign_key_constraint(:nephew_hash) |
||||
|> unique_constraint(:nephew_hash, name: :uncle_hash_to_nephew_hash) |
||||
|> unique_constraint(:nephew_hash, name: :unfetched_uncles) |
||||
|> unique_constraint(:uncle_hash, name: :nephew_hash_to_uncle_hash) |
||||
end |
||||
end |
@ -0,0 +1,63 @@ |
||||
defmodule Explorer.Chain.Transaction.Fork do |
||||
@moduledoc """ |
||||
A transaction fork has the same `hash` as a `t:Explorer.Chain.Transaction.t/0`, but associates that `hash` with a |
||||
non-consensus uncle `t:Explorer.Chain.Block.t/0` instead of the consensus block linked in the |
||||
`t:Explorer.Chain.Transaction.t/0` `block_hash`. |
||||
""" |
||||
|
||||
use Explorer.Schema |
||||
|
||||
alias Explorer.Chain.{Block, Hash, Transaction} |
||||
|
||||
@optional_attrs ~w()a |
||||
@required_attrs ~w(hash index uncle_hash)a |
||||
@allowed_attrs @optional_attrs ++ @required_attrs |
||||
|
||||
@typedoc """ |
||||
* `hash` - hash of contents of this transaction |
||||
* `index` - index of this transaction in `uncle`. |
||||
* `transaction` - the data shared between all forks and the consensus transaction. |
||||
* `uncle` - the block in which this transaction was mined/validated. |
||||
* `uncle_hash` - `uncle` foreign key. |
||||
""" |
||||
@type t :: %__MODULE__{ |
||||
hash: Hash.t(), |
||||
index: Transaction.transaction_index(), |
||||
transaction: %Ecto.Association.NotLoaded{} | Transaction.t(), |
||||
uncle: %Ecto.Association.NotLoaded{} | Block.t(), |
||||
uncle_hash: Hash.t() |
||||
} |
||||
|
||||
@primary_key false |
||||
schema "transaction_forks" do |
||||
field(:index, :integer) |
||||
|
||||
timestamps() |
||||
|
||||
belongs_to(:transaction, Transaction, foreign_key: :hash, references: :hash, type: Hash.Full) |
||||
belongs_to(:uncle, Block, foreign_key: :uncle_hash, references: :hash, type: Hash.Full) |
||||
end |
||||
|
||||
@doc """ |
||||
All fields are required for transaction fork |
||||
|
||||
iex> changeset = Fork.changeset( |
||||
...> %Fork{}, |
||||
...> %{ |
||||
...> hash: "0x3a3eb134e6792ce9403ea4188e5e79693de9e4c94e499db132be086400da79e6", |
||||
...> index: 1, |
||||
...> uncle_hash: "0xe52d77084cab13a4e724162bcd8c6028e5ecfaa04d091ee476e96b9958ed6b48" |
||||
...> } |
||||
...> ) |
||||
iex> changeset.valid? |
||||
true |
||||
|
||||
""" |
||||
def changeset(%__MODULE__{} = fork, attrs \\ %{}) do |
||||
fork |
||||
|> cast(attrs, @allowed_attrs) |
||||
|> validate_required(@required_attrs) |
||||
|> assoc_constraint(:transaction) |
||||
|> assoc_constraint(:uncle) |
||||
end |
||||
end |
@ -0,0 +1,22 @@ |
||||
defmodule Explorer.Repo.Migrations.CreateBlockSecondDegreeRelations do |
||||
use Ecto.Migration |
||||
|
||||
def change do |
||||
create table(:block_second_degree_relations, primary_key: false) do |
||||
add(:nephew_hash, references(:blocks, column: :hash, type: :bytea), null: false) |
||||
add(:uncle_hash, :bytea, null: false) |
||||
add(:uncle_fetched_at, :utc_datetime, default: fragment("NULL"), null: true) |
||||
end |
||||
|
||||
create(unique_index(:block_second_degree_relations, [:nephew_hash, :uncle_hash], name: :nephew_hash_to_uncle_hash)) |
||||
|
||||
create( |
||||
unique_index(:block_second_degree_relations, [:nephew_hash, :uncle_hash], |
||||
name: :unfetched_uncles, |
||||
where: "uncle_fetched_at IS NULL" |
||||
) |
||||
) |
||||
|
||||
create(unique_index(:block_second_degree_relations, [:uncle_hash, :nephew_hash], name: :uncle_hash_to_nephew_hash)) |
||||
end |
||||
end |
@ -0,0 +1,16 @@ |
||||
defmodule Explorer.Repo.Migrations.CreateTransactionBlockUncles do |
||||
use Ecto.Migration |
||||
|
||||
def change do |
||||
create table(:transaction_forks, primary_key: false) do |
||||
add(:hash, references(:transactions, column: :hash, on_delete: :delete_all, type: :bytea), null: false) |
||||
add(:index, :integer, null: false) |
||||
add(:uncle_hash, references(:blocks, column: :hash, on_delete: :delete_all, type: :bytea), null: false) |
||||
|
||||
timestamps() |
||||
end |
||||
|
||||
create(index(:transaction_forks, :uncle_hash)) |
||||
create(unique_index(:transaction_forks, [:uncle_hash, :index])) |
||||
end |
||||
end |
@ -0,0 +1,50 @@ |
||||
defmodule Explorer.Chain.Block.SecondDegreeRelationTest do |
||||
use Explorer.DataCase, async: true |
||||
|
||||
alias Ecto.Changeset |
||||
alias Explorer.Chain.Block |
||||
|
||||
describe "changeset/2" do |
||||
test "requires hash and nephew_hash" do |
||||
assert %Changeset{valid?: false} = |
||||
changeset = Block.SecondDegreeRelation.changeset(%Block.SecondDegreeRelation{}, %{}) |
||||
|
||||
assert changeset_errors(changeset) == %{nephew_hash: ["can't be blank"], uncle_hash: ["can't be blank"]} |
||||
|
||||
assert %Changeset{valid?: true} = |
||||
Block.SecondDegreeRelation.changeset(%Block.SecondDegreeRelation{}, %{ |
||||
nephew_hash: block_hash(), |
||||
uncle_hash: block_hash() |
||||
}) |
||||
end |
||||
|
||||
test "allows uncle_fetched_at" do |
||||
assert %Changeset{changes: %{uncle_fetched_at: _}, valid?: true} = |
||||
Block.SecondDegreeRelation.changeset(%Block.SecondDegreeRelation{}, %{ |
||||
nephew_hash: block_hash(), |
||||
uncle_hash: block_hash(), |
||||
uncle_fetched_at: DateTime.utc_now() |
||||
}) |
||||
end |
||||
|
||||
test "enforces foreign key constraint on nephew_hash" do |
||||
assert {:error, %Changeset{valid?: false} = changeset} = |
||||
%Block.SecondDegreeRelation{} |
||||
|> Block.SecondDegreeRelation.changeset(%{nephew_hash: block_hash(), uncle_hash: block_hash()}) |
||||
|> Repo.insert() |
||||
|
||||
assert changeset_errors(changeset) == %{nephew_hash: ["does not exist"]} |
||||
end |
||||
|
||||
test "enforces unique constraints on {nephew_hash, uncle_hash}" do |
||||
%Block.SecondDegreeRelation{nephew_hash: nephew_hash, uncle_hash: hash} = insert(:block_second_degree_relation) |
||||
|
||||
assert {:error, %Changeset{valid?: false} = changeset} = |
||||
%Block.SecondDegreeRelation{} |
||||
|> Block.SecondDegreeRelation.changeset(%{nephew_hash: nephew_hash, uncle_hash: hash}) |
||||
|> Repo.insert() |
||||
|
||||
assert changeset_errors(changeset) == %{uncle_hash: ["has already been taken"]} |
||||
end |
||||
end |
||||
end |
@ -0,0 +1,23 @@ |
||||
defmodule Explorer.Chain.Transaction.ForkTest do |
||||
use Explorer.DataCase |
||||
|
||||
alias Ecto.Changeset |
||||
alias Explorer.Chain.Transaction.Fork |
||||
|
||||
doctest Fork |
||||
|
||||
test "a transaction fork cannot be inserted if the corresponding transaction does not exist" do |
||||
assert %Changeset{valid?: true} = changeset = Fork.changeset(%Fork{}, params_for(:transaction_fork)) |
||||
|
||||
assert {:error, %Changeset{errors: [transaction: {"does not exist", []}]}} = Repo.insert(changeset) |
||||
end |
||||
|
||||
test "a transaction fork cannot be inserted if the corresponding uncle does not exist" do |
||||
transaction = insert(:transaction) |
||||
|
||||
assert %Changeset{valid?: true} = |
||||
changeset = Fork.changeset(%Fork{}, %{hash: transaction.hash, index: 0, uncle_hash: block_hash()}) |
||||
|
||||
assert {:error, %Changeset{errors: [uncle: {"does not exist", []}]}} = Repo.insert(changeset) |
||||
end |
||||
end |
@ -0,0 +1,154 @@ |
||||
defmodule Indexer.Block.Uncle.Fetcher do |
||||
@moduledoc """ |
||||
Fetches `t:Explorer.Chain.Block.t/0` by `hash` and updates `t:Explorer.Chain.Block.SecondDegreeRelation.t/0` |
||||
`uncle_fetched_at` where the `uncle_hash` matches `hash`. |
||||
""" |
||||
|
||||
require Logger |
||||
|
||||
alias Explorer.Chain |
||||
alias Explorer.Chain.Hash |
||||
alias Indexer.{AddressExtraction, Block, BufferedTask} |
||||
|
||||
@behaviour Block.Fetcher |
||||
@behaviour BufferedTask |
||||
|
||||
@defaults [ |
||||
flush_interval: :timer.seconds(3), |
||||
max_batch_size: 10, |
||||
max_concurrency: 10, |
||||
init_chunk_size: 1000, |
||||
task_supervisor: Indexer.Block.Uncle.TaskSupervisor |
||||
] |
||||
|
||||
@doc """ |
||||
Asynchronously fetches `t:Explorer.Chain.Block.t/0` for the given `hashes` and updates |
||||
`t:Explorer.Chain.Block.SecondDegreeRelation.t/0` `block_fetched_at`. |
||||
""" |
||||
@spec async_fetch_blocks([Hash.Full.t()]) :: :ok |
||||
def async_fetch_blocks(block_hashes) when is_list(block_hashes) do |
||||
BufferedTask.buffer( |
||||
__MODULE__, |
||||
block_hashes |
||||
|> Enum.map(&to_string/1) |
||||
|> Enum.uniq() |
||||
) |
||||
end |
||||
|
||||
@doc false |
||||
def child_spec([init_options, gen_server_options]) when is_list(init_options) do |
||||
{state, mergeable_init_options} = Keyword.pop(init_options, :block_fetcher) |
||||
|
||||
unless state do |
||||
raise ArgumentError, |
||||
":json_rpc_named_arguments must be provided to `#{__MODULE__}.child_spec " <> |
||||
"to allow for json_rpc calls when running." |
||||
end |
||||
|
||||
merged_init_options = |
||||
@defaults |
||||
|> Keyword.merge(mergeable_init_options) |
||||
|> Keyword.put(:state, %Block.Fetcher{state | broadcast: false, callback_module: __MODULE__}) |
||||
|
||||
Supervisor.child_spec({BufferedTask, [{__MODULE__, merged_init_options}, gen_server_options]}, id: __MODULE__) |
||||
end |
||||
|
||||
@impl BufferedTask |
||||
def init(initial, reducer, _) do |
||||
{:ok, final} = |
||||
Chain.stream_unfetched_uncle_hashes(initial, fn uncle_hash, acc -> |
||||
uncle_hash |
||||
|> to_string() |
||||
|> reducer.(acc) |
||||
end) |
||||
|
||||
final |
||||
end |
||||
|
||||
@impl BufferedTask |
||||
def run(hashes, _retries, %Block.Fetcher{json_rpc_named_arguments: json_rpc_named_arguments} = block_fetcher) do |
||||
# the same block could be included as an uncle on multiple blocks, but we only want to fetch it once |
||||
unique_hashes = Enum.uniq(hashes) |
||||
|
||||
Logger.debug(fn -> "fetching #{length(unique_hashes)} uncle blocks" end) |
||||
|
||||
case EthereumJSONRPC.fetch_blocks_by_hash(unique_hashes, json_rpc_named_arguments) do |
||||
{:ok, |
||||
%{ |
||||
blocks: blocks_params, |
||||
transactions: transactions_params, |
||||
block_second_degree_relations: block_second_degree_relations_params |
||||
}} -> |
||||
addresses_params = |
||||
AddressExtraction.extract_addresses(%{blocks: blocks_params, transactions: transactions_params}) |
||||
|
||||
{:ok, _} = |
||||
Block.Fetcher.import(block_fetcher, %{ |
||||
addresses: %{params: addresses_params}, |
||||
blocks: %{params: blocks_params}, |
||||
block_second_degree_relations: %{params: block_second_degree_relations_params}, |
||||
transactions: %{params: transactions_params, on_conflict: :nothing} |
||||
}) |
||||
|
||||
:ok |
||||
|
||||
{:error, reason} -> |
||||
Logger.debug(fn -> "failed to fetch #{length(unique_hashes)} uncle blocks, #{inspect(reason)}" end) |
||||
{:retry, unique_hashes} |
||||
end |
||||
end |
||||
|
||||
@impl Block.Fetcher |
||||
def import(_, options) when is_map(options) do |
||||
with {:ok, %{block_second_degree_relations: block_second_degree_relations}} = ok <- |
||||
options |
||||
|> uncle_blocks() |
||||
|> fork_transactions() |
||||
|> Chain.import() do |
||||
# * CoinBalance.Fetcher.async_fetch_balances is not called because uncles don't affect balances |
||||
# * InternalTransaction.Fetcher.async_fetch is not called because internal transactions are based on transaction |
||||
# hash, which is shared with transaction on consensus blocks. |
||||
# * Token.Fetcher.async_fetch is not called because the tokens only matter on consensus blocks |
||||
# * TokenBalance.Fetcher.async_fetch is not called because it uses block numbers from consensus, not uncles |
||||
|
||||
block_second_degree_relations |
||||
|> Enum.map(& &1.uncle_hash) |
||||
|> Block.Uncle.Fetcher.async_fetch_blocks() |
||||
|
||||
ok |
||||
end |
||||
end |
||||
|
||||
defp uncle_blocks(chain_import_options) do |
||||
put_in(chain_import_options, [:blocks, :params, Access.all(), :consensus], false) |
||||
end |
||||
|
||||
defp fork_transactions(chain_import_options) do |
||||
transactions_params = chain_import_options[:transactions][:params] || [] |
||||
|
||||
chain_import_options |
||||
|> put_in([:transactions, :params], forked_transactions_params(transactions_params)) |
||||
|> put_in([Access.key(:transaction_forks, %{}), :params], transaction_forks_params(transactions_params)) |
||||
end |
||||
|
||||
defp forked_transactions_params(transactions_params) do |
||||
# With no block_hash, there will be a collision for the same hash when a transaction is used in more than 1 uncle, |
||||
# so use MapSet to prevent duplicate row errors. |
||||
MapSet.new(transactions_params, fn transaction_params -> |
||||
Map.merge(transaction_params, %{ |
||||
block_hash: nil, |
||||
block_number: nil, |
||||
index: nil, |
||||
gas_used: nil, |
||||
cumulative_gas_used: nil, |
||||
status: nil |
||||
}) |
||||
end) |
||||
end |
||||
|
||||
defp transaction_forks_params(transactions_params) do |
||||
Enum.map(transactions_params, fn %{block_hash: uncle_hash, index: index, hash: hash} -> |
||||
%{uncle_hash: uncle_hash, index: index, hash: hash} |
||||
end) |
||||
end |
||||
end |
@ -0,0 +1,38 @@ |
||||
defmodule Indexer.Block.Uncle.Supervisor do |
||||
@moduledoc """ |
||||
Supervises `Indexer.Block.Uncle.Fetcher`. |
||||
""" |
||||
|
||||
use Supervisor |
||||
|
||||
alias Indexer.Block.Uncle.Fetcher |
||||
|
||||
def child_spec([init_arguments]) do |
||||
child_spec([init_arguments, []]) |
||||
end |
||||
|
||||
def child_spec([_init_arguments, _gen_server_options] = start_link_arguments) do |
||||
default = %{ |
||||
id: __MODULE__, |
||||
start: {__MODULE__, :start_link, start_link_arguments}, |
||||
type: :supervisor |
||||
} |
||||
|
||||
Supervisor.child_spec(default, []) |
||||
end |
||||
|
||||
def start_link(arguments, gen_server_options \\ []) do |
||||
Supervisor.start_link(__MODULE__, arguments, gen_server_options) |
||||
end |
||||
|
||||
@impl Supervisor |
||||
def init(fetcher_arguments) do |
||||
Supervisor.init( |
||||
[ |
||||
{Task.Supervisor, name: Indexer.Block.Uncle.TaskSupervisor}, |
||||
{Fetcher, [fetcher_arguments, [name: Fetcher]]} |
||||
], |
||||
strategy: :rest_for_one |
||||
) |
||||
end |
||||
end |
@ -0,0 +1,115 @@ |
||||
defmodule Indexer.Block.Catchup.FetcherTest do |
||||
use EthereumJSONRPC.Case, async: false |
||||
use Explorer.DataCase |
||||
|
||||
import Mox |
||||
|
||||
alias Indexer.{Block, CoinBalance, InternalTransaction, Token, TokenBalance} |
||||
alias Indexer.Block.Catchup.Fetcher |
||||
|
||||
@moduletag capture_log: true |
||||
|
||||
# MUST use global mode because we aren't guaranteed to get `start_supervised`'s pid back fast enough to `allow` it to |
||||
# use expectations and stubs from test's pid. |
||||
setup :set_mox_global |
||||
|
||||
setup :verify_on_exit! |
||||
|
||||
setup do |
||||
# Uncle don't occur on POA chains, so there's no way to test this using the public addresses, so mox-only testing |
||||
%{ |
||||
json_rpc_named_arguments: [ |
||||
transport: EthereumJSONRPC.Mox, |
||||
transport_options: [], |
||||
# Which one does not matter, so pick one |
||||
variant: EthereumJSONRPC.Parity |
||||
] |
||||
} |
||||
end |
||||
|
||||
describe "import/1" do |
||||
test "fetches uncles asynchronously", %{json_rpc_named_arguments: json_rpc_named_arguments} do |
||||
CoinBalance.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) |
||||
InternalTransaction.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) |
||||
Token.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) |
||||
TokenBalance.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) |
||||
|
||||
parent = self() |
||||
|
||||
pid = |
||||
spawn_link(fn -> |
||||
receive do |
||||
{:"$gen_call", from, {:buffer, uncles}} -> |
||||
GenServer.reply(from, :ok) |
||||
send(parent, {:uncles, uncles}) |
||||
end |
||||
end) |
||||
|
||||
Process.register(pid, Block.Uncle.Fetcher) |
||||
|
||||
nephew_hash = block_hash() |> to_string() |
||||
uncle_hash = block_hash() |> to_string() |
||||
miner_hash = address_hash() |> to_string() |
||||
block_number = 0 |
||||
|
||||
assert {:ok, _} = |
||||
Fetcher.import(%Block.Fetcher{json_rpc_named_arguments: json_rpc_named_arguments}, %{ |
||||
addresses: %{ |
||||
params: [ |
||||
%{hash: miner_hash} |
||||
] |
||||
}, |
||||
address_hash_to_fetched_balance_block_number: %{miner_hash => block_number}, |
||||
balances: %{ |
||||
params: [ |
||||
%{ |
||||
address_hash: miner_hash, |
||||
block_number: block_number |
||||
} |
||||
] |
||||
}, |
||||
blocks: %{ |
||||
params: [ |
||||
%{ |
||||
difficulty: 0, |
||||
gas_limit: 21000, |
||||
gas_used: 21000, |
||||
miner_hash: miner_hash, |
||||
nonce: 0, |
||||
number: block_number, |
||||
parent_hash: |
||||
block_hash() |
||||
|> to_string(), |
||||
size: 0, |
||||
timestamp: DateTime.utc_now(), |
||||
total_difficulty: 0, |
||||
hash: nephew_hash |
||||
} |
||||
] |
||||
}, |
||||
block_second_degree_relations: %{ |
||||
params: [ |
||||
%{ |
||||
nephew_hash: nephew_hash, |
||||
uncle_hash: uncle_hash |
||||
} |
||||
] |
||||
}, |
||||
tokens: %{ |
||||
params: [], |
||||
on_conflict: :nothing |
||||
}, |
||||
token_balances: %{ |
||||
params: [] |
||||
}, |
||||
transactions: %{ |
||||
params: [], |
||||
on_conflict: :nothing |
||||
}, |
||||
transaction_hash_to_block_number: %{} |
||||
}) |
||||
|
||||
assert_receive {:uncles, [^uncle_hash]} |
||||
end |
||||
end |
||||
end |
@ -0,0 +1,126 @@ |
||||
defmodule Indexer.Block.Uncle.FetcherTest do |
||||
# MUST be `async: false` so that {:shared, pid} is set for connection to allow CoinBalanceFetcher's self-send to have |
||||
# connection allowed immediately. |
||||
use EthereumJSONRPC.Case, async: false |
||||
use Explorer.DataCase |
||||
|
||||
alias Explorer.Chain |
||||
alias Indexer.Block |
||||
|
||||
import Mox |
||||
|
||||
@moduletag :capture_log |
||||
|
||||
# MUST use global mode because we aren't guaranteed to get `start_supervised`'s pid back fast enough to `allow` it to |
||||
# use expectations and stubs from test's pid. |
||||
setup :set_mox_global |
||||
|
||||
setup :verify_on_exit! |
||||
|
||||
setup do |
||||
# Uncle don't occur on POA chains, so there's no way to test this using the public addresses, so mox-only testing |
||||
%{ |
||||
json_rpc_named_arguments: [ |
||||
transport: EthereumJSONRPC.Mox, |
||||
transport_options: [], |
||||
# Which one does not matter, so pick one |
||||
variant: EthereumJSONRPC.Parity |
||||
] |
||||
} |
||||
end |
||||
|
||||
describe "child_spec/1" do |
||||
test "raises ArgumentError is `json_rpc_named_arguments is not provided" do |
||||
assert_raise ArgumentError, |
||||
":json_rpc_named_arguments must be provided to `Elixir.Indexer.Block.Uncle.Fetcher.child_spec " <> |
||||
"to allow for json_rpc calls when running.", |
||||
fn -> |
||||
start_supervised({Block.Uncle.Fetcher, [[], []]}) |
||||
end |
||||
end |
||||
end |
||||
|
||||
describe "init/1" do |
||||
test "fetched unfetched uncle hashes", %{json_rpc_named_arguments: json_rpc_named_arguments} do |
||||
assert %Chain.Block.SecondDegreeRelation{nephew_hash: nephew_hash, uncle_hash: uncle_hash, uncle: nil} = |
||||
:block_second_degree_relation |
||||
|> insert() |
||||
|> Repo.preload([:nephew, :uncle]) |
||||
|
||||
uncle_hash_data = to_string(uncle_hash) |
||||
uncle_uncle_hash_data = to_string(block_hash()) |
||||
|
||||
EthereumJSONRPC.Mox |
||||
|> expect(:json_rpc, fn [%{method: "eth_getBlockByHash", params: [^uncle_hash_data, true]}], _ -> |
||||
number_quantity = "0x0" |
||||
|
||||
{:ok, |
||||
[ |
||||
%{ |
||||
result: %{ |
||||
"author" => "0xe2ac1c6843a33f81ae4935e5ef1277a392990381", |
||||
"difficulty" => "0xfffffffffffffffffffffffffffffffe", |
||||
"extraData" => "0xd583010a068650617269747986312e32362e32826c69", |
||||
"gasLimit" => "0x7a1200", |
||||
"gasUsed" => "0x0", |
||||
"hash" => uncle_hash_data, |
||||
"logsBloom" => "0x", |
||||
"miner" => "0xe2ac1c6843a33f81ae4935e5ef1277a392990381", |
||||
"number" => number_quantity, |
||||
"parentHash" => "0x006edcaa1e6fde822908783bc4ef1ad3675532d542fce53537557391cfe34c3c", |
||||
"size" => "0x243", |
||||
"timestamp" => "0x5b437f41", |
||||
"totalDifficulty" => "0x342337ffffffffffffffffffffffffed8d29bb", |
||||
"transactions" => [ |
||||
%{ |
||||
"blockHash" => uncle_hash_data, |
||||
"blockNumber" => number_quantity, |
||||
"chainId" => "0x4d", |
||||
"condition" => nil, |
||||
"creates" => "0xffc87239eb0267bc3ca2cd51d12fbf278e02ccb4", |
||||
"from" => "0xe8ddc5c7a2d2f0d7a9798459c0104fdf5e987aca", |
||||
"gas" => "0x47b760", |
||||
"gasPrice" => "0x174876e800", |
||||
"hash" => "0x3a3eb134e6792ce9403ea4188e5e79693de9e4c94e499db132be086400da79e6", |
||||
"input" => "0x", |
||||
"nonce" => "0x0", |
||||
"r" => "0xad3733df250c87556335ffe46c23e34dbaffde93097ef92f52c88632a40f0c75", |
||||
"s" => "0x72caddc0371451a58de2ca6ab64e0f586ccdb9465ff54e1c82564940e89291e3", |
||||
"standardV" => "0x0", |
||||
"to" => nil, |
||||
"transactionIndex" => "0x0", |
||||
"v" => "0xbd", |
||||
"value" => "0x0" |
||||
} |
||||
], |
||||
"uncles" => [uncle_uncle_hash_data] |
||||
} |
||||
} |
||||
]} |
||||
end) |
||||
|
||||
Block.Uncle.Supervisor.Case.start_supervised!( |
||||
block_fetcher: %Block.Fetcher{json_rpc_named_arguments: json_rpc_named_arguments} |
||||
) |
||||
|
||||
wait(fn -> |
||||
Repo.one!( |
||||
from(bsdr in Chain.Block.SecondDegreeRelation, |
||||
where: bsdr.nephew_hash == ^nephew_hash and not is_nil(bsdr.uncle_fetched_at) |
||||
) |
||||
) |
||||
end) |
||||
|
||||
refute is_nil(Repo.get(Chain.Block, uncle_hash)) |
||||
assert Repo.aggregate(Chain.Transaction.Fork, :count, :hash) == 1 |
||||
end |
||||
end |
||||
|
||||
defp wait(producer) do |
||||
producer.() |
||||
rescue |
||||
Ecto.NoResultsError -> |
||||
Process.sleep(100) |
||||
wait(producer) |
||||
end |
||||
end |
@ -0,0 +1,18 @@ |
||||
defmodule Indexer.Block.Uncle.Supervisor.Case do |
||||
alias Indexer.Block |
||||
|
||||
def start_supervised!(fetcher_arguments \\ []) when is_list(fetcher_arguments) do |
||||
merged_fetcher_arguments = |
||||
Keyword.merge( |
||||
fetcher_arguments, |
||||
flush_interval: 50, |
||||
init_chunk_size: 1, |
||||
max_batch_size: 1, |
||||
max_concurrency: 1 |
||||
) |
||||
|
||||
[merged_fetcher_arguments] |
||||
|> Block.Uncle.Supervisor.child_spec() |
||||
|> ExUnit.Callbacks.start_supervised!() |
||||
end |
||||
end |