Skip to content

Commit

Permalink
feat: add /accounts/:id/activities endpoint (#906)
Browse files Browse the repository at this point in the history
* feat: add /accounts/:id/activities endpoint

Initial approach for dealing with account activities.

This endpoint contains exclusively events generated by direct
transactions (no internal transactions generated by contract calls).

The next steps are:
  * Including contract call events.
  * Including key-block and micro-block events (e.g. block_mined, dev_reward).
  * Including AEx9 and AEx141 events.
  * Refactor AeMdw.Txs so that it uses the Field module as well.

* fix: return proper structure for activities with same txi

* docs: add activities endpoint to README

* refactor: use plain "type" and "payload" for activities

* chore: remove name_claim_tx type from default tx activities

* test: add activity controller unit tests as well

* docs: add activities/contract events distinction on README

* chore: remove child contract creations from activities
  • Loading branch information
sborrazas committed Sep 21, 2022
1 parent ac672ed commit 950f738
Show file tree
Hide file tree
Showing 10 changed files with 586 additions and 10 deletions.
87 changes: 87 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
- [AEX9 contract balances](#aex9-contract-balances)
- [NFTs](#aex141)
- [Statistics](#statistics)
- [Activities](#activities)
- [Migrating to v2](#migrating-to-v2)
- [Websocket interface](#websocket-interface)
- [Tests](#tests)
Expand Down Expand Up @@ -4392,6 +4393,92 @@ $ curl -s "https://mainnet.aeternity.io/mdw/v2/minerstats?limit=1" | jq '.'
"prev": null
}
```
---

## Activities

Inteded for being able to display all events in which a specific account is related to in any way.

An activity event occurs when there's any change in the blockchain related to a specific account. It is not the same as the log events which occur when executing a contract.

### `/v2/accounts/:id/activities`

Paginated list of events related to the `:id` account.

Each activity contains 3 values:
- `height` - The height in which the event ocurred
- `type` - The type of event.
- `payload` - An object whose structure depends on the type of event.

For transaction events the activity type will be `<TxType>Event`, and the payload will contain a single transaction object as displayed in the `/v2/txs` endpoint.

```
$ curl https://mainnet.aeternity.io/mdw/v2/accounts/ak_2nVdbZTBVTbAet8j2hQhLmfNm1N2WKoAGyL7abTAHF1wGMPjzx/activities
{
"data": [
{
"height": 85694,
"type": "NameUpdateTxEvent",
"payload": {
"block_hash": "mh_2tL4tRRnH6WLzzYca7T7vQUbdUCRZEeq58S5giwAtnbkjjb3Vj",
"block_height": 85694,
"hash": "th_2pvhiLSonrEsmJiUf9Q3E3Lkt9ki5MpHGJ9qQsCVt8ACNWpVVc",
"micro_index": 30,
"micro_time": 1558804725247,
"signatures": [
"sg_P7UFr4iySfJpidyitDqVTF86uhnuYjQVJ46c96jC4nYZys5mBDQVbsV4CLxYpCqKU55SySmkcSg3Xg4dcYk4aFJGm3VjF"
],
"tx": {
"account_id": "ak_2nVdbZTBVTbAet8j2hQhLmfNm1N2WKoAGyL7abTAHF1wGMPjzx",
"client_ttl": 84600,
"fee": 30000000000000,
"name": "umpz.test",
"name_id": "nm_t13Kcjan1mRu2sFjdMgeeASSSL8QoxmVhTrFCmji1j1DZ4jhb",
"name_ttl": 50000,
"nonce": 151,
"pointers": [
{
"id": "ak_2nVdbZTBVTbAet8j2hQhLmfNm1N2WKoAGyL7abTAHF1wGMPjzx",
"key": "account_pubkey"
}
],
"type": "NameUpdateTx",
"version": 1
}
}
},
{
"payload": {
"block_hash": "mh_2iWGwtQYYueZ8wLGTBjQ79jYfLnQKNgVcHc1GWuPqMG46UPnHY",
"block_height": 502033,
"hash": "th_29qxc2oEajHPVoGNS6LBe2TbKk2kyECXXR4KtbGHMhfpwdoNzD",
"micro_index": 0,
"micro_time": 1634367215608,
"signatures": [
"sg_DXk5jcdoCgGVHJUqjL2Mnu3tPxFD2mGrPga5TgVH97DZC1oq7aDZKEHgrpBqf24A4v2oBFX3zHQzXC1wj9X4ZqdzsqJqj"
],
"tx": {
"amount": 20000,
"fee": 19320000000000,
"nonce": 5967045,
"payload": "ba_NTAyMDMxOmtoXzJraWtpTms0cnJnV2lNZlBLSmszU2FCdnM5TVVqdHZtNEpLeTdoVnA3Z2k5eW1uaXF1Om1oX01TZ2dxenJINlpXOW9xbmM3eXZDR1dBdGlGRGpaWGFrQWZSVndmeWtteGdWdEd3aVY6MTYzNDM2NzIxMCoV6Eo=",
"recipient_id": "ak_2QkttUgEyPixKzqXkJ4LX7ugbRjwCDWPBT4p4M2r8brjxUxUYd",
"sender_id": "ak_2QkttUgEyPixKzqXkJ4LX7ugbRjwCDWPBT4p4M2r8brjxUxUYd",
"ttl": 502041,
"type": "SpendTx",
"version": 1
}
},
"type": "SpendTxEvent",
"height": 502033
}
],
"next": "/v2/accounts/ak_2nVdbZTBVTbAet8j2hQhLmfNm1N2WKoAGyL7abTAHF1wGMPjzx/activities?cursor=84328-2002003-0",
"prev": null
}
```



## Migrating to v2

Expand Down
147 changes: 147 additions & 0 deletions lib/ae_mdw/activities.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
defmodule AeMdw.Activities do
@moduledoc """
Activities context module.
"""
alias AeMdw.Blocks
alias AeMdw.Collection
alias AeMdw.Db.Model
alias AeMdw.Db.State
alias AeMdw.Error
alias AeMdw.Error.Input, as: ErrInput
alias AeMdw.Fields
alias AeMdw.Node
alias AeMdw.Txs
alias AeMdw.Validate

require Model

@type activity() :: map()

@typep state() :: State.t()
@typep pagination() :: Collection.direction_limit()
@typep range() :: {:gen, Range.t()} | nil
@typep query() :: map()
@typep cursor() :: binary() | nil
@typep txi() :: Txs.txi()
@typep activity_key() :: {Blocks.height(), txi(), non_neg_integer()}
@typep activity_value() :: {:field, Node.tx_type(), non_neg_integer() | nil}
@typep activity_pair() :: {activity_key(), activity_value()}

@doc """
Activities related to an account are those that affect the account in any way.
The paginated activities returned follow the transactions order, and include the following:
* Key blocks
* Block mined {gen, -1, 0}
* Miner rewards {gen, -1, 1..X}
* Micro blocks
* Block mined {gen, -1, X+1..}
* Transactions
* If spend_tx, oracle, channels, etc include all senders/recipient's info {gen, A, 0..X}
* If contract_create or contract_call include:
* All remote calls recusively {gen, A, X+1..Y}
* All internal events {gen, A, Y+1..}
Internally an activity is identified by the tuple {height, txi, local_idx}:
* `height` - The key block height
* `txi` - If the activity belongs to a transaction
* `local_idx` - If there's more than one activity per txi, then this index is used, starting from 0.
These are a few examples of different activities that the build_*_stream functions would return:
* `{{10, -1, 0}, :block_mined}` - The first activity belonging to the key block 10.
* `{{10, 40, 0}, {:field, :spend_tx, 1}}` - The first activity belonging to the transaction with txi 40 (from height 10),
where the first field of the spend transaction is the account's being queried.
"""
@spec fetch_account_activities(state(), binary(), pagination(), range(), query(), cursor()) ::
{:ok, activity() | nil, [activity()], activity() | nil} | {:error, Error.t()}
def fetch_account_activities(state, account, pagination, range, _query, cursor) do
with {:ok, account_pk} <- Validate.id(account),
{:ok, cursor} <- deserialize_cursor(cursor) do
{prev_cursor, activities_locators_data, next_cursor} =
fn direction ->
gens_stream = build_gens_stream(state, direction, account_pk, range, cursor)
txs_stream = build_txs_stream(state, direction, account_pk, range, cursor)

Collection.merge([txs_stream, gens_stream], direction)
end
|> Collection.paginate(pagination)

{:ok, serialize_cursor(prev_cursor), Enum.map(activities_locators_data, &render(state, &1)),
serialize_cursor(next_cursor)}
end
end

defp build_gens_stream(_state, _direction, _account_pk, _range, _cursor) do
[]
end

defp build_txs_stream(state, direction, account_pk, range, cursor) do
{txi_cursor, local_idx_cursor} =
case cursor do
{_height, txi, local_idx} -> {txi, local_idx}
nil -> {nil, nil}
end

stream =
state
|> Fields.account_fields_stream(account_pk, direction, range, txi_cursor)
|> Stream.transform({-1, -1, -1}, fn
{txi, tx_type, tx_field_pos}, {txi, height, local_idx} ->
{[{{height, txi, local_idx + 1}, {:field, tx_type, tx_field_pos}}],
{txi, height, local_idx + 1}}

{txi, tx_type, tx_field_pos}, _acc ->
Model.tx(block_index: {height, _mbi}) = State.fetch!(state, Model.Tx, txi)

{[{{height, txi, 0}, {:field, tx_type, tx_field_pos}}], {txi, height, 0}}
end)

if local_idx_cursor do
Stream.drop_while(stream, fn
{{_height, ^txi_cursor, local_idx}, _data} when direction == :forward ->
local_idx < local_idx_cursor

{{_height, ^txi_cursor, local_idx}, _data} when direction == :backward ->
local_idx > local_idx_cursor

_activity_pair ->
false
end)
else
stream
end
end

@spec render(state(), activity_pair()) :: map()
defp render(state, {{height, txi, _local_idx}, {:field, tx_type, _tx_pos}}) do
tx = state |> Txs.fetch!(txi) |> Map.delete("tx_index")

%{
height: height,
type: "#{Node.tx_name(tx_type)}Event",
payload: tx
}
end

defp serialize_cursor(nil), do: nil

defp serialize_cursor({{{height, txi, local_idx}, _data}, is_reversed?}),
do: {"#{height}-#{txi + 1}-#{local_idx}", is_reversed?}

defp deserialize_cursor(nil), do: {:ok, nil}

defp deserialize_cursor(cursor) do
case Regex.run(~r/\A(\d+)-(\d+)-(\d+)\z/, cursor, capture: :all_but_first) do
[height, txi, local_idx] ->
{:ok,
{String.to_integer(height), String.to_integer(txi) - 1, String.to_integer(local_idx)}}

nil ->
{:error, ErrInput.Cursor.exception(value: cursor)}
end
end
end
10 changes: 10 additions & 0 deletions lib/ae_mdw/db/util.ex
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ defmodule AeMdw.Db.Util do
require Model

@typep state() :: State.t()
@typep height() :: Blocks.height()
@typep direction() :: Collection.direction()

@spec read_tx!(state(), Txs.txi()) :: Model.tx()
def read_tx!(state, txi), do: State.fetch!(state, Model.Tx, txi)
Expand Down Expand Up @@ -82,6 +84,14 @@ defmodule AeMdw.Db.Util do
end
end

@spec first_gen_to_txi(state(), height(), direction()) :: height()
def first_gen_to_txi(state, first_gen, :forward), do: gen_to_txi(state, first_gen)
def first_gen_to_txi(state, first_gen, :backward), do: gen_to_txi(state, first_gen + 1) - 1

@spec last_gen_to_txi(state(), height(), direction()) :: height()
def last_gen_to_txi(state, last_gen, :forward), do: gen_to_txi(state, last_gen + 1) - 1
def last_gen_to_txi(state, last_gen, :backward), do: gen_to_txi(state, last_gen)

@spec txi_to_gen(state(), Txs.txi()) :: Blocks.height()
def txi_to_gen(state, txi) do
case State.get(state, Model.Tx, txi) do
Expand Down
73 changes: 73 additions & 0 deletions lib/ae_mdw/fields.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
defmodule AeMdw.Fields do
@moduledoc """
A simple fields querying API to filter transactions by type and/or ID.
"""

alias AeMdw.Collection
alias AeMdw.Db.Model
alias AeMdw.Db.State
alias AeMdw.Db.Util, as: DbUtil
alias AeMdw.Node
alias AeMdw.Node.Db
alias AeMdw.Txs
alias AeMdw.Util

@typep state() :: State.t()
@typep pubkey() :: Db.pubkey()
@typep direction() :: Collection.direction()
@typep range() :: {:gen, Range.t()} | nil
@typep cursor() :: Txs.tx() | nil

@create_tx_types ~w(contract_create_tx channel_create_tx oracle_register_tx ga_attach_tx)a

@spec account_fields_stream(state(), pubkey(), direction(), range(), cursor()) :: Enumerable.t()
def account_fields_stream(state, account_pk, direction, range, cursor) do
scope =
case range do
{:gen, %Range{first: first_gen, last: last_gen}} ->
{DbUtil.first_gen_to_txi(state, first_gen, direction),
DbUtil.last_gen_to_txi(state, last_gen, direction)}

nil ->
nil
end

Node.tx_types()
|> Enum.flat_map(fn tx_type ->
types_pos =
tx_type
|> Node.tx_ids()
|> Enum.map(fn {_field, pos} -> {tx_type, pos} end)

if tx_type in @create_tx_types do
[{tx_type, nil} | types_pos]
else
types_pos
end
end)
|> Enum.map(fn {tx_type, tx_field_pos} ->
scope =
case scope do
{first_txi, last_txi} ->
{{tx_type, tx_field_pos, account_pk, first_txi},
{tx_type, tx_field_pos, account_pk, last_txi}}

nil ->
{{tx_type, tx_field_pos, account_pk, Util.min_int()},
{tx_type, tx_field_pos, account_pk, Util.max_256bit_int()}}
end

cursor = if cursor, do: {tx_type, tx_field_pos, account_pk, cursor}

state
|> Collection.stream(Model.Field, direction, scope, cursor)
|> Stream.filter(fn {^tx_type, ^tx_field_pos, ^account_pk, txi} ->
tx_type != :contract_create_tx or State.exists?(state, Model.Type, {tx_type, txi})
end)
|> Stream.map(fn {^tx_type, ^tx_field_pos, ^account_pk, txi} ->
{txi, tx_type, tx_field_pos}
end)
end)
|> Collection.merge(direction)
end
end
12 changes: 2 additions & 10 deletions lib/ae_mdw/txs.ex
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,8 @@ defmodule AeMdw.Txs do
scope =
case range do
{:gen, %Range{first: first_gen, last: last_gen}} ->
{first_gen_to_txi(state, first_gen, direction),
last_gen_to_txi(state, last_gen, direction)}
{DbUtil.first_gen_to_txi(state, first_gen, direction),
DbUtil.last_gen_to_txi(state, last_gen, direction)}

{:txi, %Range{first: first_txi, last: last_txi}} ->
{first_txi, last_txi}
Expand Down Expand Up @@ -168,14 +168,6 @@ defmodule AeMdw.Txs do
tx_hash
end

defp first_gen_to_txi(state, first_gen, :forward), do: DbUtil.gen_to_txi(state, first_gen)

defp first_gen_to_txi(state, first_gen, :backward),
do: DbUtil.gen_to_txi(state, first_gen + 1) - 1

defp last_gen_to_txi(state, last_gen, :forward), do: DbUtil.gen_to_txi(state, last_gen + 1) - 1
defp last_gen_to_txi(state, last_gen, :backward), do: DbUtil.gen_to_txi(state, last_gen)

# The purpose of this function is to generate the streams that will be then used as input for
# Collection.merge/2 function. The function is divided into three clauses. There's an explanation
# before each.
Expand Down
Loading

0 comments on commit 950f738

Please sign in to comment.