Add optional params to account txlist API endpoint

Why:

* For users to be able to specify optional params when calling the API
RPC account transactions (action=txlist) endpoint.
  Example usage:
  ```
  /api?module=account&action=txlist \
  &address=0xddbd2b932c763ba5b1b7ae3b362eac3e8d40121a \
  &startblock=100&endblock=150&page=1&offset=10&sort=asc
  ```
* Issue link: https://github.com/poanetwork/poa-explorer/issues/138

This change addresses the need by:

* Editing `Explorer.Etherscan.list_transactions/2` to support
`order_by_direction`, `page_number`, `page_size`, `start_block`, and
`end_block` options.
* Editing `API.RPC.AddressController` to support 'sort', 'page',
'offset', 'startblock', and 'endblock' params. Mimics Etherscan's API.
When the optional params are not provided we return a max of 10,000
transactions, order by block number in ascending order, and target all
blocks.
pull/440/head
Sebastian Abondano 6 years ago
parent ed4900e260
commit 86de475385
  1. 56
      apps/explorer/lib/explorer/etherscan.ex
  2. 166
      apps/explorer/test/explorer/etherscan_test.exs
  3. 65
      apps/explorer_web/lib/explorer_web/controllers/api/rpc/address_controller.ex
  4. 476
      apps/explorer_web/test/explorer_web/controllers/api/rpc/address_controller_test.exs

@ -3,23 +3,41 @@ defmodule Explorer.Etherscan do
The etherscan context.
"""
import Ecto.Query,
only: [
from: 2
]
import Ecto.Query, only: [from: 2, where: 3]
alias Explorer.{Repo, Chain}
alias Explorer.Chain.{Hash, Transaction}
@default_options %{
order_by_direction: :asc,
page_number: 1,
page_size: 10_000,
start_block: nil,
end_block: nil
}
@doc """
Returns the maximum allowed page size number.
"""
@spec page_size_max :: pos_integer()
def page_size_max do
@default_options.page_size
end
@doc """
Gets a list of transactions for a given `t:Explorer.Chain.Hash.Address`.
"""
@spec list_transactions(Hash.Address.t()) :: [map()]
def list_transactions(%Hash{byte_count: unquote(Hash.Address.byte_count())} = address_hash) do
def list_transactions(
%Hash{byte_count: unquote(Hash.Address.byte_count())} = address_hash,
options \\ @default_options
) do
case Chain.max_block_number() do
{:ok, max_block_number} ->
list_transactions(address_hash, max_block_number)
merged_options = Map.merge(@default_options, options)
list_transactions(address_hash, max_block_number, merged_options)
_ ->
[]
@ -43,7 +61,7 @@ defmodule Explorer.Etherscan do
:gas_used
]
defp list_transactions(address_hash, max_block_number) do
defp list_transactions(address_hash, max_block_number, options) do
query =
from(
t in Transaction,
@ -52,8 +70,9 @@ defmodule Explorer.Etherscan do
where: t.to_address_hash == ^address_hash,
or_where: t.from_address_hash == ^address_hash,
or_where: it.transaction_hash == t.hash and it.type == ^"create",
order_by: [asc: t.block_number],
limit: 10_000,
order_by: [{^options.order_by_direction, t.block_number}],
limit: ^options.page_size,
offset: ^offset(options),
select:
merge(map(t, ^@transaction_fields), %{
block_timestamp: b.timestamp,
@ -62,6 +81,23 @@ defmodule Explorer.Etherscan do
})
)
Repo.all(query)
query
|> where_start_block_match(options)
|> where_end_block_match(options)
|> Repo.all()
end
defp where_start_block_match(query, %{start_block: nil}), do: query
defp where_start_block_match(query, %{start_block: start_block}) do
where(query, [t], t.block_number >= ^start_block)
end
defp where_end_block_match(query, %{end_block: nil}), do: query
defp where_end_block_match(query, %{end_block: end_block}) do
where(query, [t], t.block_number <= ^end_block)
end
defp offset(options), do: (options.page_number - 1) * options.page_size
end

@ -148,7 +148,7 @@ defmodule Explorer.EtherscanTest do
assert found_transaction.block_timestamp == block.timestamp
end
test "orders transactions by block, in ascending order" do
test "orders transactions by block, in ascending order (default)" do
first_block = insert(:block)
second_block = insert(:block)
address = insert(:address)
@ -171,5 +171,169 @@ defmodule Explorer.EtherscanTest do
assert block_numbers_order == Enum.sort(block_numbers_order)
end
test "orders transactions by block, in descending order" do
first_block = insert(:block)
second_block = insert(:block)
address = insert(:address)
2
|> insert_list(:transaction, from_address: address)
|> with_block(second_block)
2
|> insert_list(:transaction, from_address: address)
|> with_block()
2
|> insert_list(:transaction, from_address: address)
|> with_block(first_block)
options = %{order_by_direction: :desc}
found_transactions = Etherscan.list_transactions(address.hash, options)
block_numbers_order = Enum.map(found_transactions, & &1.block_number)
assert block_numbers_order == Enum.sort(block_numbers_order, &(&1 >= &2))
end
test "with page_size and page_number options" do
first_block = insert(:block)
second_block = insert(:block)
third_block = insert(:block)
address = insert(:address)
second_block_transactions =
2
|> insert_list(:transaction, from_address: address)
|> with_block(second_block)
third_block_transactions =
2
|> insert_list(:transaction, from_address: address)
|> with_block(third_block)
first_block_transactions =
2
|> insert_list(:transaction, from_address: address)
|> with_block(first_block)
options = %{page_number: 1, page_size: 2}
page1_transactions = Etherscan.list_transactions(address.hash, options)
page1_hashes = Enum.map(page1_transactions, & &1.hash)
assert length(page1_transactions) == 2
for transaction <- first_block_transactions do
assert transaction.hash in page1_hashes
end
options = %{page_number: 2, page_size: 2}
page2_transactions = Etherscan.list_transactions(address.hash, options)
page2_hashes = Enum.map(page2_transactions, & &1.hash)
assert length(page2_transactions) == 2
for transaction <- second_block_transactions do
assert transaction.hash in page2_hashes
end
options = %{page_number: 3, page_size: 2}
page3_transactions = Etherscan.list_transactions(address.hash, options)
page3_hashes = Enum.map(page3_transactions, & &1.hash)
assert length(page3_transactions) == 2
for transaction <- third_block_transactions do
assert transaction.hash in page3_hashes
end
options = %{page_number: 4, page_size: 2}
assert Etherscan.list_transactions(address.hash, options) == []
end
test "with start and end block options" do
blocks = [_, second_block, third_block, _] = insert_list(4, :block)
address = insert(:address)
for block <- blocks do
2
|> insert_list(:transaction, from_address: address)
|> with_block(block)
end
options = %{
start_block: second_block.number,
end_block: third_block.number
}
found_transactions = Etherscan.list_transactions(address.hash, options)
expected_block_numbers = [second_block.number, third_block.number]
assert length(found_transactions) == 4
for transaction <- found_transactions do
assert transaction.block_number in expected_block_numbers
end
end
test "with start_block but no end_block option" do
blocks = [_, _, third_block, fourth_block] = insert_list(4, :block)
address = insert(:address)
for block <- blocks do
2
|> insert_list(:transaction, from_address: address)
|> with_block(block)
end
options = %{
start_block: third_block.number
}
found_transactions = Etherscan.list_transactions(address.hash, options)
expected_block_numbers = [third_block.number, fourth_block.number]
assert length(found_transactions) == 4
for transaction <- found_transactions do
assert transaction.block_number in expected_block_numbers
end
end
test "with end_block but no start_block option" do
blocks = [first_block, second_block, _, _] = insert_list(4, :block)
address = insert(:address)
for block <- blocks do
2
|> insert_list(:transaction, from_address: address)
|> with_block(block)
end
options = %{
end_block: second_block.number
}
found_transactions = Etherscan.list_transactions(address.hash, options)
expected_block_numbers = [first_block.number, second_block.number]
assert length(found_transactions) == 4
for transaction <- found_transactions do
assert transaction.block_number in expected_block_numbers
end
end
end
end

@ -27,9 +27,11 @@ defmodule ExplorerWeb.API.RPC.AddressController do
end
def txlist(conn, params) do
options = optional_params(params)
with {:address_param, {:ok, address_param}} <- fetch_address(params),
{:format, {:ok, address_hash}} <- to_address_hash(address_param),
{:ok, transactions} <- list_transactions(address_hash) do
{:ok, transactions} <- list_transactions(address_hash, options) do
render(conn, :txlist, %{transactions: transactions})
else
{:address_param, :error} ->
@ -110,8 +112,65 @@ defmodule ExplorerWeb.API.RPC.AddressController do
{:format, Chain.string_to_address_hash(address_hash_string)}
end
defp list_transactions(address_hash) do
case Etherscan.list_transactions(address_hash) do
defp optional_params(params) do
%{}
|> put_order_by_direction(params)
|> put_pagination_options(params)
|> put_start_block(params)
|> put_end_block(params)
end
defp put_order_by_direction(options, params) do
case params do
%{"sort" => sort} when sort in ["asc", "desc"] ->
order_by_direction = String.to_existing_atom(sort)
Map.put(options, :order_by_direction, order_by_direction)
_ ->
options
end
end
defp put_pagination_options(options, params) do
with %{"page" => page, "offset" => offset} <- params,
{page_number, ""} when page_number > 0 <- Integer.parse(page),
{page_size, ""} when page_size > 0 <- Integer.parse(offset),
:ok <- validate_max_page_size(page_size) do
options
|> Map.put(:page_number, page_number)
|> Map.put(:page_size, page_size)
else
_ ->
options
end
end
defp validate_max_page_size(page_size) do
if page_size <= Etherscan.page_size_max(), do: :ok, else: :error
end
defp put_start_block(options, params) do
with %{"startblock" => startblock_param} <- params,
{start_block, ""} <- Integer.parse(startblock_param) do
Map.put(options, :start_block, start_block)
else
_ ->
options
end
end
defp put_end_block(options, params) do
with %{"endblock" => endblock_param} <- params,
{end_block, ""} <- Integer.parse(endblock_param) do
Map.put(options, :end_block, end_block)
else
_ ->
options
end
end
defp list_transactions(address_hash, options) do
case Etherscan.list_transactions(address_hash, options) do
[] -> {:error, :not_found}
transactions -> {:ok, transactions}
end

@ -480,5 +480,481 @@ defmodule ExplorerWeb.API.RPC.AddressControllerTest do
assert response["status"] == "1"
assert response["message"] == "OK"
end
test "orders transactions by block, in ascending order", %{conn: conn} do
first_block = insert(:block)
second_block = insert(:block)
third_block = insert(:block)
address = insert(:address)
2
|> insert_list(:transaction, from_address: address)
|> with_block(second_block)
2
|> insert_list(:transaction, from_address: address)
|> with_block(third_block)
2
|> insert_list(:transaction, from_address: address)
|> with_block(first_block)
params = %{
"module" => "account",
"action" => "txlist",
"address" => "#{address.hash}",
"sort" => "asc"
}
assert response =
conn
|> get("/api", params)
|> json_response(200)
block_numbers_order =
Enum.map(response["result"], fn transaction ->
String.to_integer(transaction["blockNumber"])
end)
assert block_numbers_order == Enum.sort(block_numbers_order, &(&1 <= &2))
assert response["status"] == "1"
assert response["message"] == "OK"
end
test "orders transactions by block, in descending order", %{conn: conn} do
first_block = insert(:block)
second_block = insert(:block)
third_block = insert(:block)
address = insert(:address)
2
|> insert_list(:transaction, from_address: address)
|> with_block(second_block)
2
|> insert_list(:transaction, from_address: address)
|> with_block(third_block)
2
|> insert_list(:transaction, from_address: address)
|> with_block(first_block)
params = %{
"module" => "account",
"action" => "txlist",
"address" => "#{address.hash}",
"sort" => "desc"
}
assert response =
conn
|> get("/api", params)
|> json_response(200)
block_numbers_order =
Enum.map(response["result"], fn transaction ->
String.to_integer(transaction["blockNumber"])
end)
assert block_numbers_order == Enum.sort(block_numbers_order, &(&1 >= &2))
assert response["status"] == "1"
assert response["message"] == "OK"
end
test "ignores invalid sort option, defaults to ascending", %{conn: conn} do
first_block = insert(:block)
second_block = insert(:block)
third_block = insert(:block)
address = insert(:address)
2
|> insert_list(:transaction, from_address: address)
|> with_block(second_block)
2
|> insert_list(:transaction, from_address: address)
|> with_block(third_block)
2
|> insert_list(:transaction, from_address: address)
|> with_block(first_block)
params = %{
"module" => "account",
"action" => "txlist",
"address" => "#{address.hash}",
"sort" => "invalidsortoption"
}
assert response =
conn
|> get("/api", params)
|> json_response(200)
block_numbers_order =
Enum.map(response["result"], fn transaction ->
String.to_integer(transaction["blockNumber"])
end)
assert block_numbers_order == Enum.sort(block_numbers_order, &(&1 <= &2))
assert response["status"] == "1"
assert response["message"] == "OK"
end
test "with valid pagination params", %{conn: conn} do
# To get paginated results on this endpoint Etherscan's docs say:
#
# "(To get paginated results use page=<page number> and offset=<max
# records to return>)"
first_block = insert(:block)
second_block = insert(:block)
third_block = insert(:block)
address = insert(:address)
_second_block_transactions =
2
|> insert_list(:transaction, from_address: address)
|> with_block(second_block)
_third_block_transactions =
2
|> insert_list(:transaction, from_address: address)
|> with_block(third_block)
first_block_transactions =
2
|> insert_list(:transaction, from_address: address)
|> with_block(first_block)
params = %{
"module" => "account",
"action" => "txlist",
"address" => "#{address.hash}",
# page number
"page" => "1",
# page size
"offset" => "2"
}
assert response =
conn
|> get("/api", params)
|> json_response(200)
page1_hashes = Enum.map(response["result"], & &1["hash"])
assert length(response["result"]) == 2
for transaction <- first_block_transactions do
assert "#{transaction.hash}" in page1_hashes
end
assert response["status"] == "1"
assert response["message"] == "OK"
end
test "ignores pagination params when invalid", %{conn: conn} do
first_block = insert(:block)
second_block = insert(:block)
third_block = insert(:block)
address = insert(:address)
_second_block_transactions =
2
|> insert_list(:transaction, from_address: address)
|> with_block(second_block)
_third_block_transactions =
2
|> insert_list(:transaction, from_address: address)
|> with_block(third_block)
_first_block_transactions =
2
|> insert_list(:transaction, from_address: address)
|> with_block(first_block)
params = %{
"module" => "account",
"action" => "txlist",
"address" => "#{address.hash}",
# page number
"page" => "invalidpage",
# page size
"offset" => "invalidoffset"
}
assert response =
conn
|> get("/api", params)
|> json_response(200)
assert length(response["result"]) == 6
assert response["status"] == "1"
assert response["message"] == "OK"
end
test "ignores pagination params if page is less than 1", %{conn: conn} do
address = insert(:address)
6
|> insert_list(:transaction, from_address: address)
|> with_block()
params = %{
"module" => "account",
"action" => "txlist",
"address" => "#{address.hash}",
# page number
"page" => "0",
# page size
"offset" => "2"
}
assert response =
conn
|> get("/api", params)
|> json_response(200)
assert length(response["result"]) == 6
assert response["status"] == "1"
assert response["message"] == "OK"
end
test "ignores pagination params if offset is less than 1", %{conn: conn} do
address = insert(:address)
6
|> insert_list(:transaction, from_address: address)
|> with_block()
params = %{
"module" => "account",
"action" => "txlist",
"address" => "#{address.hash}",
# page number
"page" => "1",
# page size
"offset" => "0"
}
assert response =
conn
|> get("/api", params)
|> json_response(200)
assert length(response["result"]) == 6
assert response["status"] == "1"
assert response["message"] == "OK"
end
test "ignores pagination params if offset is over 10,000", %{conn: conn} do
address = insert(:address)
6
|> insert_list(:transaction, from_address: address)
|> with_block()
params = %{
"module" => "account",
"action" => "txlist",
"address" => "#{address.hash}",
# page number
"page" => "1",
# page size
"offset" => "10_500"
}
assert response =
conn
|> get("/api", params)
|> json_response(200)
assert length(response["result"]) == 6
assert response["status"] == "1"
assert response["message"] == "OK"
end
test "with page number with no results", %{conn: conn} do
first_block = insert(:block)
second_block = insert(:block)
third_block = insert(:block)
address = insert(:address)
_second_block_transactions =
2
|> insert_list(:transaction, from_address: address)
|> with_block(second_block)
_third_block_transactions =
2
|> insert_list(:transaction, from_address: address)
|> with_block(third_block)
_first_block_transactions =
2
|> insert_list(:transaction, from_address: address)
|> with_block(first_block)
params = %{
"module" => "account",
"action" => "txlist",
"address" => "#{address.hash}",
# page number
"page" => "5",
# page size
"offset" => "2"
}
assert response =
conn
|> get("/api", params)
|> json_response(200)
assert response["result"] == []
assert response["status"] == "0"
assert response["message"] == "No transactions found"
end
test "with startblock and endblock params", %{conn: conn} do
blocks = [_, second_block, third_block, _] = insert_list(4, :block)
address = insert(:address)
for block <- blocks do
2
|> insert_list(:transaction, from_address: address)
|> with_block(block)
end
params = %{
"module" => "account",
"action" => "txlist",
"address" => "#{address.hash}",
"startblock" => "#{second_block.number}",
"endblock" => "#{third_block.number}"
}
expected_block_numbers = [
"#{second_block.number}",
"#{third_block.number}"
]
assert response =
conn
|> get("/api", params)
|> json_response(200)
assert length(response["result"]) == 4
for transaction <- response["result"] do
assert transaction["blockNumber"] in expected_block_numbers
end
assert response["status"] == "1"
assert response["message"] == "OK"
end
test "with startblock but without endblock", %{conn: conn} do
blocks = [_, _, third_block, fourth_block] = insert_list(4, :block)
address = insert(:address)
for block <- blocks do
2
|> insert_list(:transaction, from_address: address)
|> with_block(block)
end
params = %{
"module" => "account",
"action" => "txlist",
"address" => "#{address.hash}",
"startblock" => "#{third_block.number}"
}
expected_block_numbers = [
"#{third_block.number}",
"#{fourth_block.number}"
]
assert response =
conn
|> get("/api", params)
|> json_response(200)
assert length(response["result"]) == 4
for transaction <- response["result"] do
assert transaction["blockNumber"] in expected_block_numbers
end
assert response["status"] == "1"
assert response["message"] == "OK"
end
test "with endblock but without startblock", %{conn: conn} do
blocks = [first_block, second_block, _, _] = insert_list(4, :block)
address = insert(:address)
for block <- blocks do
2
|> insert_list(:transaction, from_address: address)
|> with_block(block)
end
params = %{
"module" => "account",
"action" => "txlist",
"address" => "#{address.hash}",
"endblock" => "#{second_block.number}"
}
expected_block_numbers = [
"#{first_block.number}",
"#{second_block.number}"
]
assert response =
conn
|> get("/api", params)
|> json_response(200)
assert length(response["result"]) == 4
for transaction <- response["result"] do
assert transaction["blockNumber"] in expected_block_numbers
end
assert response["status"] == "1"
assert response["message"] == "OK"
end
test "ignores invalid startblock and endblock", %{conn: conn} do
blocks = [_, _, _, _] = insert_list(4, :block)
address = insert(:address)
for block <- blocks do
2
|> insert_list(:transaction, from_address: address)
|> with_block(block)
end
params = %{
"module" => "account",
"action" => "txlist",
"address" => "#{address.hash}",
"startblock" => "invalidstart",
"endblock" => "invalidend"
}
assert response =
conn
|> get("/api", params)
|> json_response(200)
assert length(response["result"]) == 8
assert response["status"] == "1"
assert response["message"] == "OK"
end
end
end

Loading…
Cancel
Save