commit
e29123a02b
@ -1,6 +1,45 @@ |
|||||||
.transaction-details-address { |
.transaction-bottom-panel { |
||||||
font-size: 12px; |
display: flex; |
||||||
font-weight: bold; |
flex-direction: column; |
||||||
line-height: 1.2; |
@media (min-width: 768px) { |
||||||
margin: 0 0 12px; |
flex-direction: row; |
||||||
|
justify-content: space-between; |
||||||
|
align-items: flex-end; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
.transaction-bottom-panel { |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
@media (min-width: 768px) { |
||||||
|
flex-direction: row; |
||||||
|
justify-content: space-between; |
||||||
|
align-items: flex-end; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
.download-all-transactions { |
||||||
|
text-align: center; |
||||||
|
color: #a3a9b5; |
||||||
|
font-size: 13px; |
||||||
|
margin-top: 10px; |
||||||
|
@media (min-width: 768px) { |
||||||
|
margin-top: 30px; |
||||||
|
} |
||||||
|
.download-all-transactions-link { |
||||||
|
text-decoration: none; |
||||||
|
svg { |
||||||
|
position: relative; |
||||||
|
margin-left: 2px; |
||||||
|
top: -3px; |
||||||
|
path { |
||||||
|
fill: $primary; |
||||||
|
} |
||||||
|
} |
||||||
|
&:hover { |
||||||
|
span { |
||||||
|
text-decoration: underline; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
} |
} |
@ -0,0 +1,119 @@ |
|||||||
|
defmodule Explorer.Chain.AddressTokenTransferCsvExporter do |
||||||
|
@moduledoc """ |
||||||
|
Exports token transfers to a csv file. |
||||||
|
""" |
||||||
|
|
||||||
|
alias Explorer.{Chain, PagingOptions} |
||||||
|
alias Explorer.Chain.{Address, TokenTransfer, Transaction} |
||||||
|
alias NimbleCSV.RFC4180 |
||||||
|
|
||||||
|
@necessity_by_association [ |
||||||
|
necessity_by_association: %{ |
||||||
|
[created_contract_address: :names] => :optional, |
||||||
|
[from_address: :names] => :optional, |
||||||
|
[to_address: :names] => :optional, |
||||||
|
[token_transfers: :token] => :optional, |
||||||
|
[token_transfers: :to_address] => :optional, |
||||||
|
[token_transfers: :from_address] => :optional, |
||||||
|
[token_transfers: :token_contract_address] => :optional, |
||||||
|
:block => :required |
||||||
|
} |
||||||
|
] |
||||||
|
|
||||||
|
@page_size 150 |
||||||
|
@paging_options %PagingOptions{page_size: @page_size + 1} |
||||||
|
|
||||||
|
def export(address) do |
||||||
|
address |
||||||
|
|> fetch_all_transactions(@paging_options) |
||||||
|
|> to_token_transfers() |
||||||
|
|> to_csv_format(address) |
||||||
|
|> dump_to_stream() |
||||||
|
end |
||||||
|
|
||||||
|
defp fetch_all_transactions(address, paging_options, acc \\ []) do |
||||||
|
options = Keyword.merge(@necessity_by_association, paging_options: paging_options) |
||||||
|
|
||||||
|
transactions = |
||||||
|
address |
||||||
|
|> Chain.address_to_transactions_with_rewards(options) |
||||||
|
|> Enum.filter(fn transaction -> Enum.count(transaction.token_transfers) > 0 end) |
||||||
|
|
||||||
|
new_acc = transactions ++ acc |
||||||
|
|
||||||
|
case Enum.split(transactions, @page_size) do |
||||||
|
{_transactions, [%Transaction{block_number: block_number, index: index}]} -> |
||||||
|
new_paging_options = %{@paging_options | key: {block_number, index}} |
||||||
|
fetch_all_transactions(address, new_paging_options, new_acc) |
||||||
|
|
||||||
|
{_, []} -> |
||||||
|
new_acc |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
defp to_token_transfers(transactions) do |
||||||
|
transactions |
||||||
|
|> Enum.flat_map(fn transaction -> |
||||||
|
transaction.token_transfers |
||||||
|
|> Enum.map(fn transfer -> %{transfer | transaction: transaction} end) |
||||||
|
end) |
||||||
|
end |
||||||
|
|
||||||
|
defp dump_to_stream(transactions) do |
||||||
|
transactions |
||||||
|
|> RFC4180.dump_to_stream() |
||||||
|
end |
||||||
|
|
||||||
|
defp to_csv_format(token_transfers, address) do |
||||||
|
row_names = [ |
||||||
|
"TxHash", |
||||||
|
"BlockNumber", |
||||||
|
"UnixTimestamp", |
||||||
|
"FromAddress", |
||||||
|
"ToAddress", |
||||||
|
"TokenContractAddress", |
||||||
|
"Type", |
||||||
|
"TokenSymbol", |
||||||
|
"TokensTransferred", |
||||||
|
"TransactionFee", |
||||||
|
"Status", |
||||||
|
"ErrCode" |
||||||
|
] |
||||||
|
|
||||||
|
token_transfer_lists = |
||||||
|
token_transfers |
||||||
|
|> Stream.map(fn token_transfer -> |
||||||
|
[ |
||||||
|
to_string(token_transfer.transaction_hash), |
||||||
|
token_transfer.transaction.block_number, |
||||||
|
token_transfer.transaction.block.timestamp, |
||||||
|
token_transfer.from_address |> to_string() |> String.downcase(), |
||||||
|
token_transfer.to_address |> to_string() |> String.downcase(), |
||||||
|
token_transfer.token_contract_address |> to_string() |> String.downcase(), |
||||||
|
type(token_transfer, address), |
||||||
|
token_transfer.token.symbol, |
||||||
|
token_transfer.amount, |
||||||
|
fee(token_transfer.transaction), |
||||||
|
token_transfer.transaction.status, |
||||||
|
token_transfer.transaction.error |
||||||
|
] |
||||||
|
end) |
||||||
|
|
||||||
|
Stream.concat([row_names], token_transfer_lists) |
||||||
|
end |
||||||
|
|
||||||
|
defp type(%TokenTransfer{from_address_hash: from_address}, %Address{hash: from_address}), do: "OUT" |
||||||
|
|
||||||
|
defp type(%TokenTransfer{to_address_hash: to_address}, %Address{hash: to_address}), do: "IN" |
||||||
|
|
||||||
|
defp type(_, _), do: "" |
||||||
|
|
||||||
|
defp fee(transaction) do |
||||||
|
transaction |
||||||
|
|> Chain.fee(:wei) |
||||||
|
|> case do |
||||||
|
{:actual, value} -> value |
||||||
|
{:maximum, value} -> "Max of #{value}" |
||||||
|
end |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,139 @@ |
|||||||
|
defmodule Explorer.Chain.AddressTransactionCsvExporter do |
||||||
|
@moduledoc """ |
||||||
|
Exports transactions to a csv file. |
||||||
|
""" |
||||||
|
|
||||||
|
import Ecto.Query, |
||||||
|
only: [ |
||||||
|
from: 2 |
||||||
|
] |
||||||
|
|
||||||
|
alias Explorer.{Chain, Market, PagingOptions, Repo} |
||||||
|
alias Explorer.Market.MarketHistory |
||||||
|
alias Explorer.Chain.{Address, Transaction, Wei} |
||||||
|
alias Explorer.ExchangeRates.Token |
||||||
|
alias NimbleCSV.RFC4180 |
||||||
|
|
||||||
|
@necessity_by_association [ |
||||||
|
necessity_by_association: %{ |
||||||
|
[created_contract_address: :names] => :optional, |
||||||
|
[from_address: :names] => :optional, |
||||||
|
[to_address: :names] => :optional, |
||||||
|
[token_transfers: :token] => :optional, |
||||||
|
[token_transfers: :to_address] => :optional, |
||||||
|
[token_transfers: :from_address] => :optional, |
||||||
|
[token_transfers: :token_contract_address] => :optional, |
||||||
|
:block => :required |
||||||
|
} |
||||||
|
] |
||||||
|
|
||||||
|
@page_size 150 |
||||||
|
|
||||||
|
@paging_options %PagingOptions{page_size: @page_size + 1} |
||||||
|
|
||||||
|
@spec export(Address.t()) :: Enumerable.t() |
||||||
|
def export(address) do |
||||||
|
exchange_rate = Market.get_exchange_rate(Explorer.coin()) || Token.null() |
||||||
|
|
||||||
|
address |
||||||
|
|> fetch_all_transactions(@paging_options) |
||||||
|
|> to_csv_format(address, exchange_rate) |
||||||
|
|> dump_to_stream() |
||||||
|
end |
||||||
|
|
||||||
|
defp fetch_all_transactions(address, paging_options, acc \\ []) do |
||||||
|
options = Keyword.merge(@necessity_by_association, paging_options: paging_options) |
||||||
|
|
||||||
|
transactions = Chain.address_to_transactions_with_rewards(address, options) |
||||||
|
|
||||||
|
new_acc = transactions ++ acc |
||||||
|
|
||||||
|
case Enum.split(transactions, @page_size) do |
||||||
|
{_transactions, [%Transaction{block_number: block_number, index: index}]} -> |
||||||
|
new_paging_options = %{@paging_options | key: {block_number, index}} |
||||||
|
fetch_all_transactions(address, new_paging_options, new_acc) |
||||||
|
|
||||||
|
{_, []} -> |
||||||
|
new_acc |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
defp dump_to_stream(transactions) do |
||||||
|
transactions |
||||||
|
|> RFC4180.dump_to_stream() |
||||||
|
end |
||||||
|
|
||||||
|
defp to_csv_format(transactions, address, exchange_rate) do |
||||||
|
row_names = [ |
||||||
|
"TxHash", |
||||||
|
"BlockNumber", |
||||||
|
"UnixTimestamp", |
||||||
|
"FromAddress", |
||||||
|
"ToAddress", |
||||||
|
"ContractAddress", |
||||||
|
"Type", |
||||||
|
"Value", |
||||||
|
"Fee", |
||||||
|
"Status", |
||||||
|
"ErrCode", |
||||||
|
"CurrentPrice", |
||||||
|
"TxDateOpeningPrice", |
||||||
|
"TxDateClosingPrice" |
||||||
|
] |
||||||
|
|
||||||
|
transaction_lists = |
||||||
|
transactions |
||||||
|
|> Stream.map(fn transaction -> |
||||||
|
{opening_price, closing_price} = price_at_date(transaction.block.timestamp) |
||||||
|
|
||||||
|
[ |
||||||
|
to_string(transaction.hash), |
||||||
|
transaction.block_number, |
||||||
|
transaction.block.timestamp, |
||||||
|
to_string(transaction.from_address), |
||||||
|
to_string(transaction.to_address), |
||||||
|
to_string(transaction.created_contract_address), |
||||||
|
type(transaction, address), |
||||||
|
Wei.to(transaction.value, :wei), |
||||||
|
fee(transaction), |
||||||
|
transaction.status, |
||||||
|
transaction.error, |
||||||
|
exchange_rate.usd_value, |
||||||
|
opening_price, |
||||||
|
closing_price |
||||||
|
] |
||||||
|
end) |
||||||
|
|
||||||
|
Stream.concat([row_names], transaction_lists) |
||||||
|
end |
||||||
|
|
||||||
|
defp type(%Transaction{from_address_hash: from_address}, %Address{hash: from_address}), do: "OUT" |
||||||
|
|
||||||
|
defp type(%Transaction{to_address_hash: to_address}, %Address{hash: to_address}), do: "IN" |
||||||
|
|
||||||
|
defp type(_, _), do: "" |
||||||
|
|
||||||
|
defp fee(transaction) do |
||||||
|
transaction |
||||||
|
|> Chain.fee(:wei) |
||||||
|
|> case do |
||||||
|
{:actual, value} -> value |
||||||
|
{:maximum, value} -> "Max of #{value}" |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
defp price_at_date(datetime) do |
||||||
|
date = DateTime.to_date(datetime) |
||||||
|
|
||||||
|
query = |
||||||
|
from( |
||||||
|
mh in MarketHistory, |
||||||
|
where: mh.date == ^date |
||||||
|
) |
||||||
|
|
||||||
|
case Repo.one(query) do |
||||||
|
nil -> {nil, nil} |
||||||
|
price -> {price.opening_price, price.closing_price} |
||||||
|
end |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,72 @@ |
|||||||
|
defmodule Explorer.Chain.AddressTokenTransferCsvExporterTest do |
||||||
|
use Explorer.DataCase |
||||||
|
|
||||||
|
alias Explorer.Chain.AddressTokenTransferCsvExporter |
||||||
|
|
||||||
|
describe "export/1" do |
||||||
|
test "exports token transfers to csv" do |
||||||
|
address = insert(:address) |
||||||
|
|
||||||
|
transaction = |
||||||
|
:transaction |
||||||
|
|> insert(from_address: address) |
||||||
|
|> with_block() |
||||||
|
|
||||||
|
token_transfer = insert(:token_transfer, transaction: transaction, from_address: address) |
||||||
|
|
||||||
|
[result] = |
||||||
|
address |
||||||
|
|> AddressTokenTransferCsvExporter.export() |
||||||
|
|> Enum.to_list() |
||||||
|
|> Enum.drop(1) |
||||||
|
|> Enum.map(fn [ |
||||||
|
tx_hash, |
||||||
|
_, |
||||||
|
block_number, |
||||||
|
_, |
||||||
|
timestamp, |
||||||
|
_, |
||||||
|
from_address, |
||||||
|
_, |
||||||
|
to_address, |
||||||
|
_, |
||||||
|
token_contract_address, |
||||||
|
_, |
||||||
|
type, |
||||||
|
_, |
||||||
|
token_symbol, |
||||||
|
_, |
||||||
|
tokens_transferred, |
||||||
|
_, |
||||||
|
transaction_fee, |
||||||
|
_, |
||||||
|
status, |
||||||
|
_, |
||||||
|
err_code, |
||||||
|
_ |
||||||
|
] -> |
||||||
|
%{ |
||||||
|
tx_hash: tx_hash, |
||||||
|
block_number: block_number, |
||||||
|
timestamp: timestamp, |
||||||
|
from_address: from_address, |
||||||
|
to_address: to_address, |
||||||
|
token_contract_address: token_contract_address, |
||||||
|
type: type, |
||||||
|
token_symbol: token_symbol, |
||||||
|
tokens_transferred: tokens_transferred, |
||||||
|
transaction_fee: transaction_fee, |
||||||
|
status: status, |
||||||
|
err_code: err_code |
||||||
|
} |
||||||
|
end) |
||||||
|
|
||||||
|
assert result.block_number == to_string(transaction.block_number) |
||||||
|
assert result.tx_hash == to_string(transaction.hash) |
||||||
|
assert result.from_address == token_transfer.from_address_hash |> to_string() |> String.downcase() |
||||||
|
assert result.to_address == token_transfer.to_address_hash |> to_string() |> String.downcase() |
||||||
|
assert result.timestamp == to_string(transaction.block.timestamp) |
||||||
|
assert result.type == "OUT" |
||||||
|
end |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,105 @@ |
|||||||
|
defmodule Explorer.Chain.AddressTransactionCsvExporterTest do |
||||||
|
use Explorer.DataCase |
||||||
|
|
||||||
|
alias Explorer.Chain.{AddressTransactionCsvExporter, Wei} |
||||||
|
|
||||||
|
describe "export/1" do |
||||||
|
test "exports address transactions to csv" do |
||||||
|
address = insert(:address) |
||||||
|
|
||||||
|
transaction = |
||||||
|
:transaction |
||||||
|
|> insert(from_address: address) |
||||||
|
|> with_block() |
||||||
|
|> Repo.preload(:token_transfers) |
||||||
|
|
||||||
|
[result] = |
||||||
|
address |
||||||
|
|> AddressTransactionCsvExporter.export() |
||||||
|
|> Enum.to_list() |
||||||
|
|> Enum.drop(1) |
||||||
|
|> Enum.map(fn [ |
||||||
|
hash, |
||||||
|
_, |
||||||
|
block_number, |
||||||
|
_, |
||||||
|
timestamp, |
||||||
|
_, |
||||||
|
from_address, |
||||||
|
_, |
||||||
|
to_address, |
||||||
|
_, |
||||||
|
created_address, |
||||||
|
_, |
||||||
|
type, |
||||||
|
_, |
||||||
|
value, |
||||||
|
_, |
||||||
|
fee, |
||||||
|
_, |
||||||
|
status, |
||||||
|
_, |
||||||
|
error, |
||||||
|
_, |
||||||
|
cur_price, |
||||||
|
_, |
||||||
|
op_price, |
||||||
|
_, |
||||||
|
cl_price, |
||||||
|
_ |
||||||
|
] -> |
||||||
|
%{ |
||||||
|
hash: hash, |
||||||
|
block_number: block_number, |
||||||
|
timestamp: timestamp, |
||||||
|
from_address: from_address, |
||||||
|
to_address: to_address, |
||||||
|
created_address: created_address, |
||||||
|
type: type, |
||||||
|
value: value, |
||||||
|
fee: fee, |
||||||
|
status: status, |
||||||
|
error: error, |
||||||
|
current_price: cur_price, |
||||||
|
opening_price: op_price, |
||||||
|
closing_price: cl_price |
||||||
|
} |
||||||
|
end) |
||||||
|
|
||||||
|
assert result.block_number == to_string(transaction.block_number) |
||||||
|
assert result.timestamp |
||||||
|
assert result.created_address == to_string(transaction.created_contract_address_hash) |
||||||
|
assert result.from_address == to_string(transaction.from_address) |
||||||
|
assert result.to_address == to_string(transaction.to_address) |
||||||
|
assert result.hash == to_string(transaction.hash) |
||||||
|
assert result.type == "OUT" |
||||||
|
assert result.value == transaction.value |> Wei.to(:wei) |> to_string() |
||||||
|
assert result.fee |
||||||
|
assert result.status == to_string(transaction.status) |
||||||
|
assert result.error == to_string(transaction.error) |
||||||
|
assert result.current_price |
||||||
|
assert result.opening_price |
||||||
|
assert result.closing_price |
||||||
|
end |
||||||
|
|
||||||
|
test "fetches all transactions" do |
||||||
|
address = insert(:address) |
||||||
|
|
||||||
|
1..200 |
||||||
|
|> Enum.map(fn _ -> |
||||||
|
:transaction |
||||||
|
|> insert(from_address: address) |
||||||
|
|> with_block() |
||||||
|
end) |
||||||
|
|> Enum.count() |
||||||
|
|
||||||
|
result = |
||||||
|
address |
||||||
|
|> AddressTransactionCsvExporter.export() |
||||||
|
|> Enum.to_list() |
||||||
|
|> Enum.drop(1) |
||||||
|
|
||||||
|
assert Enum.count(result) == 200 |
||||||
|
end |
||||||
|
end |
||||||
|
end |
Loading…
Reference in new issue