DEV Community

Cover image for Transaction log events decoding: making wallet activity understandable
Nazhmudin Baimurzaev
Nazhmudin Baimurzaev

Posted on • Updated on

Transaction log events decoding: making wallet activity understandable

Introduction

In this article, I will explain how to transform raw transaction data related to a crypto wallet into clear, human-readable information about wallet activity.

We will look at best examples of how popular web3 products (wallets, portfolio trackers etc.) visualize complex wallet activity information, and then we will implement on typescript the logic responsible for preparing data for such visualization (github repository with code).

But first, we need to familiarize ourselves with the theory of Events and Logs in EVM blockchains.

Transactions Events and Logs 101

I won't delve too deeply into the theory; for that, there's an excellent article about the theory of Events and Logs ("Everything You Ever Wanted to Know About Events and Logs on Ethereum"). I will just make a brief summary of this theory.

Events

Event is a log entity which EVM smart contracts can emit during transaction execution.
Events are very good at signalling that an some action has taken place on-chain.
Applications can subscribe and listen to events to trigger some off-chain logic or they can index, transform and store events in some off-chain storage (look at The Graph protocol or Ethereum ETL).

Lets look the events of Open Zeppelin’s ERC20 token contract:

/**
 * @dev Interface of the ERC20 standard as defined in the EIP.
 */
interface IERC20 {
    /**
     * @dev Emitted when `value` tokens are moved from one account (`from`) to
     * another (`to`).
     *
     * Note that `value` may be zero.
     */
    event Transfer(address indexed from, address indexed to, uint256 value);

    /**
     * @dev Emitted when the allowance of a `spender` for an `owner` is set by
     * a call to {approve}. `value` is the new allowance.
     */
    event Approval(address indexed owner, address indexed spender, uint256 value);
}
Enter fullscreen mode Exit fullscreen mode

Open Zeppelin’s ERC20 contract call/creates events using the emit keyword:

abstract contract ERC20 is Context, IERC20, IERC20Metadata, IERC20Errors {
    /**
     * @dev Transfers a `value` amount of tokens from `from` to `to`, or alternatively mints (or burns) if `from`
     * (or `to`) is the zero address. All customizations to transfers, mints, and burns should be done by overriding
     * this function.
     *
     * Emits a {Transfer} event.
     */
    function _update(address from, address to, uint256 value) internal virtual {
        if (from == address(0)) {
            // Overflow check required: The rest of the code assumes that totalSupply never overflows
            _totalSupply += value;
        } else {
            uint256 fromBalance = _balances[from];
            if (fromBalance < value) {
                revert ERC20InsufficientBalance(from, fromBalance, value);
            }
            unchecked {
                // Overflow not possible: value <= fromBalance <= totalSupply.
                _balances[from] = fromBalance - value;
            }
        }

        if (to == address(0)) {
            unchecked {
                // Overflow not possible: value <= totalSupply or value <= fromBalance <= totalSupply.
                _totalSupply -= value;
            }
        } else {
            unchecked {
                // Overflow not possible: balance + value is at most totalSupply, which we know fits into a uint256.
                _balances[to] += value;
            }
        }

        emit Transfer(from, to, value);
    }
}

Enter fullscreen mode Exit fullscreen mode

Logs

When a smart contract emits an event, the event name and its arguments are stored in the transaction's log entity.
The logs can be read using JSON-RPC of blockchain node or using custom blockchain data provider.

Transaction receipt with event logs for example ERC-20 transfer transaction will looks like this

{
    "jsonrpc":"2.0",
    "id":1,
    "result":{
        "transactionHash":"0xa81b0b764ea32179b29c1098378992bed1b9a53b04f180393f0438d02da1687e",
        "blockHash":"0x93dbccc61ff09a4ba65dacf26c35d2035373aca9453c65f65523ce9cb8700b41",
        "blockNumber":"0x115f5a9",
        "logs":[
            {
                "transactionHash":"0xa81b0b764ea32179b29c1098378992bed1b9a53b04f180393f0438d02da1687e",
                "address":"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
                "blockHash":"0x93dbccc61ff09a4ba65dacf26c35d2035373aca9453c65f65523ce9cb8700b41",
                "blockNumber":"0x115f5a9",
                "data":"0x0000000000000000000000000000000000000000000000000000000ba43b7400",
                "logIndex":"0x178",
                "removed":false,
                "topics":[
                    "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",
                    "0x0000000000000000000000004a7c6899cdcb379e284fbfd045462e751da4c7ce",
                    "0x000000000000000000000000f1ddf1fc0310cb11f0ca87508207012f4a9cb336"
                ],
                "transactionIndex":"0x67"
            }
        ],
        "contractAddress":null,
        "effectiveGasPrice":"0x1e6d855cc",
        "cumulativeGasUsed":"0x9717a6",
        "from":"0x4a7c6899cdcb379e284fbfd045462e751da4c7ce",
        "gasUsed":"0x10059",
        "logsBloom":"0x00000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000000000000000000000000008000008000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000010000000000008000000000000000000000020000000000000010000000000000000000000000000000000200000000000000000000000000000000000000000000000000020000002000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000000",
        "status":"0x1",
        "to":"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
        "transactionIndex":"0x67",
        "type":"0x2"
    }
}

Enter fullscreen mode Exit fullscreen mode
interface Log {
  blockNumber: number;
  blockHash: string;
  transactionIndex: number;
  removed: boolean;
  address: string;
  data: string;
  topics: Array<string>;
  transactionHash: string;
  logIndex: number;
}

Enter fullscreen mode Exit fullscreen mode

A field called “logs” contains a log object for every event emitted during transaction execution.

How events stored in logs (ABI, Event signature, Topics, Data)

There is a special artefact that can be generated during contract compilation called contract’s ABI.
ABI is a lower-level representation of that contract interface (description of constructor, methods and events).

Let's look at the Transfer event from the ERC20 standard, the event looks like this in the ABI.

{
    "anonymous":false,
    "inputs":[
        {
            "indexed":true,
            "internalType":"address",
            "name":"from",
            "type":"address"
        },
        {
            "indexed":true,
            "internalType":"address",
            "name":"to",
            "type":"address"
        },
        {
            "indexed":false,
            "internalType":"uint256",
            "name":"value",
            "type":"uint256"
        }
    ],
    "name":"Transfer",
    "type":"event"
}
Enter fullscreen mode Exit fullscreen mode

To identify and store specific events in a log, they're hashed into an "event signature". For instance, the Transfer event's signature is Transfer(address,address,uint256) (the name of the event, and then pair it with the types of inputs).

The result of hashing (keccak256) this signature is 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef and this hash can be found in the log's "topics" (always the first topic).
Indexed arguments (e.g. from and to) are stored in topics as well and unindexed arguments (e.g. value) in data.

If we look at log object of example ERC-20 transfer transaction we will find hashed event as first topic.

{
    "transactionHash":"0xa81b0b764ea32179b29c1098378992bed1b9a53b04f180393f0438d02da1687e",
    "address":"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
    "blockHash":"0x93dbccc61ff09a4ba65dacf26c35d2035373aca9453c65f65523ce9cb8700b41",
    "blockNumber":"0x115f5a9",

    // encoded value amount
    "data":"0x0000000000000000000000000000000000000000000000000000000ba43b7400",

    "logIndex":"0x178",
    "removed":false,
    "topics":[
        // hashed event signature
        "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",

        //encoded from/sender address
        "0x0000000000000000000000004a7c6899cdcb379e284fbfd045462e751da4c7ce",

        //encoded to/recipient address
        "0x000000000000000000000000f1ddf1fc0310cb11f0ca87508207012f4a9cb336"
    ],
    "transactionIndex":"0x67"
}
Enter fullscreen mode Exit fullscreen mode

Decoding this data is possible using contract ABI and such libraries like Ethers.js, which simplifies this process.
If you want to go deeper, you can read "How to Decode Events From Logs" paragraph of the "Everything You Ever Wanted to Know About Events and Logs on Ethereum" article and understand how ethers do this.

Examples of transaction visualization

The most popular use case for events decoding is visualizing of wallet activity in a human-readable format. Apart from this, there are interesting use cases like transaction result simulation and notification, but I can cover those cases in a separate article.

Most popular wallets handle visualization of wallet activity quite well, but I've decided to highlight the best examples.

Further down in the article in all examples I will will use the Nansen CEO's wallet 0x4a7c6899cdcb379e284fbfd045462e751da4c7ce(wallet page on etherscan) to study how different web3 products visualize the transaction history of a wallet (wallet activity) and for our own implementation.

Visualization for technically savvy users by Etherscan

Let's start with the very first interface for working with data from the blockchain, which is Etherscan.

Etherscan is the go-to interface when you need to read some information from the blockchain. But it's designed for technically proficient users and isn't suitable for ordinary users.

Image description

For instance, consider a transaction that sends 50k USDC tokens from a wallet. When you visit the transaction page, under the sections "Transaction Action" and "ERC-20 Tokens Transferred," you can see the visualization of the transfer (from where, to where, how much, and of what).

This information is generated based on the logs/events that occurred within this transaction. This is where the decoding of logs/events comes into play, which are produced by the ERC-20 smart contract of the USDC token.

In the screenshot below, we can see the logged event "Transfer". Properly decoding this log from the event allows us to prepare data for visualization blocks like "Transaction Action" and "ERC-20 Tokens Transferred".

Image description

Image description

Etherscan handles the visualization of basic transactions that are called when interacting with standart ERC-20/ERC-721/ERC-1155 contracts. In the list of Token-transfers/NFT-transfers, such transactions will be identified and well visualized.

However, for more complex transactions arising from interactions with different protocols (for example, token exchanges on DEX-protocols, NFT trading on Marketplace protocols), Etherscan won't visualize this in a user-friendly manner. It will simply decode logs into events if the corresponding contracts have verifiable code on ethersscan.

Of course, if you go, for instance, to the Trade transaction page of a wallet on the DEX-protocol Uniswap, you'll see the actual actions (like swap and transfers) that took place , but you'll also see a lot of extraneous information not directly related to the wallet making the exchange. This information (various transfers) pertains to the underlying actions that occurred to execute the transaction.

Image description

Easy-to-understand visualization for average users by Zerion

Let's see how Zerion handles the task.

In the screenshot below, we see a page with the transaction history in a human-readable format, without unnecessary details and with good visualization.

Here we have transactions for sending and receiving tokens, token exchange transactions on Uniswap, and transaction permitting the spending of tokens from the wallet balance for a specific protocol/application.

Image description

Other examples of human-readable visualization by Zapper, Interface app, Rainbow wallet

Zapper app

Image description

Interface app

Image description
Image description

Rainbow wallet

Image description

As we can see, all these products visualize the transaction history in a very similar way. Only the final UX/UI and terminology differ.

But all these products share a common approach to data preparation for visualization.
It's based on the approach of decoding logs from smart contract events, enriching events with information (Token / NFT meta (logo, name, ticker), wallets meta (ENS, Lens profile)), and preparing the final data structure for activity visualization.

Coding transactions decoding and data preparation logic (Zerion-inspired approach)

Now, let's try to implement the logic that will prepare data for visualizing transactions similar to Zerion.

Breaking down the transaction interface into its components

Let's break down the cases of sending/receiving an ERC-20 token and ERC-20 token exchange/swap and then try to understand what information we need to extract from the raw transaction data.

It is important to note that UI in all applications is rendered from the perspective of the wallet to which the transactions belong.
A transaction always has 2 participants (sender/receiver account or smart contract) and one and the same transaction looks differently depending on which account we are looking at it from.

ERC-20 Token send/receive transaction

Image description

Image description

Transaction UI here can be divided into 3 main components:

  • Transaction type - send or receive type, as USDC or USDT tokens are transferred between wallets and no other actions.
  • Transaction actions - token transfer action, as USDC or USDT tokens are being withdrawn or deposited to wallets.
  • Transaction participant - to/recipient or from/sender participant, as in case of send transaction action it is to/recipient, in case of receive it is from/sender participant.

Token trade/swap transaction

Image description
Transaction UI here can be divided into 3 main components:

  • Transaction type - trade/swap type, as MEF token is exchanged to USDC token according to some market price on Uniswap DEX application.
  • Transaction actions - token transfer actions, as MEF tokens are being deducted from the balance and USDC tokens are being added. MEF is OUT token and USDC is IN token.
  • Transaction participant - application, wallet interacts with Uniswap DEX application/protocol which is responsible for tokens exchange/swap operation.

Designing data structures

Based on our research of Zerion UX let's define 3 main UI elements of Transaction.

  • Transaction type - defines the nature or purpose of the transaction, it specifies what the transaction is intended to do. (Send, Receive, Swap, Approve, Arbitrary contract execution)
  • Transaction actions - describe the specific steps or operations involved in the transaction, these actions depend on the transaction type. (Token transfer action, Token swap action, Token approve action, NFT mint action)
  • Transaction participant - identify the entities or accounts involved in the transaction, this participant depends on the transaction type. (Sender account, Recipient account, Contract/Application/Protocol)

So, our basic entities/types will be presented below. A full set is available in file transactions.types.ts.

Transaction Direction

This is an enumeration that defines the possible directions of a transaction. It can be 'IN', 'OUT', or 'SELF'.

enum TransactionDirection {
    'IN' = 'IN',
    'OUT' = 'OUT',
    'SELF' = 'SELF'
}
Enter fullscreen mode Exit fullscreen mode

Transaction Action

  • TransactionTransferAction defines the structure of a transfer action. It includes the token being transferred (of type Token), the value being transferred, the sender and receiver of the transfer, and the direction of the transfer (of type TransactionDirection).
  • TransactionSwapAction defines the structure of a swap action.
interface TransactionActionBase {
    type: 'TRANSFER' | 'SWAP'
}

interface TransactionTransferAction extends TransactionActionBase {
    type: 'TRANSFER'
    token: Token;
    value: string;
    from: Account;
    to: Account;
    direction: TransactionDirection;
}

interface TransactionSwapAction extends TransactionActionBase {
    type: 'SWAP'
    trader: Account;
    application: Account;
}

type TransactionAction = TransactionTransferAction | TransactionSwapAction
Enter fullscreen mode Exit fullscreen mode

Transaction type

TransactionType is an enumeration that defines the possible types of a transaction. It can be 'SEND_TOKEN', 'RECEIVE', 'RECEIVE_TOKEN', 'EXECUTION', 'SEND_NFT', 'RECEIVE_NFT', or 'SWAP'.

enum TransactionType {
    'SEND' = 'SEND',
    'RECEIVE' = 'RECEIVE',
    'SELL' = 'SELL',
    'EXECUTION' = 'EXECUTION',
    'SEND_NFT' = 'SEND_NFT',
    'RECEIVE_NFT' = 'RECEIVE_NFT'
}
Enter fullscreen mode Exit fullscreen mode

Transaction

Transaction is an interface that defines the structure of a transaction. It includes the hash of the transaction, the chain ID, the type and status of the transaction (of types TransactionType and TransactionStatus respectively), the execution time, the fee, the sender and receiver addresses, the value of the transaction, the direction of the transaction (of type TransactionDirection), the actions involved in the transaction (of type TransactionAction[]), and the wallet address, name, and ID.

interface Transaction {
    hash: string;
    chainId: number | null;
    type: TransactionType;
    status: TransactionStatus;
    executed: string;
    gasFee: string;
    fromAddress: string | null;
    toAddress: string | null;
    value: string | null;
    direction: TransactionDirection;
    transactionActions: TransactionAction[] | null;
    walletAddress: string | null;
    walletName?: string | null;
    walletId?: string | null;
}
Enter fullscreen mode Exit fullscreen mode

Getting raw transaction data

To form a transaction object like this, we primarily need to obtain the basic properties of the transaction and the corresponding transaction receipt, which appears after the transaction has been mined (added into a block).

For this, we will use the JSON-RPC node provider Alchemy (this could also be Infura, Quicknode or something else).

Additionally, we will need the Ethers library to utilize the JSON-RPC methods of the provider: eth_getTransactionByHash and eth_getTransactionReceipt.

Function decodeWalletTransactions

The decodeWalletTransactions function is a service that fetches and decodes Ethereum transactions for a specific wallet address. It's used to provide a more human-readable format of the transactions, which can be useful for displaying transaction data in a user interface or for further analysis

The decodeWalletTransactions function is available in file scripts/decodeWalletTransactions.ts.

async function decodeWalletTransactions() {
  const provider = new ethers.providers.AlchemyProvider(ETHEREUM_CHAIN_ID, ALCHEMY_KEY)

  const transactionsRawRequests = testTransactions.map(async (txHash) => {
    const transaction = await provider.getTransaction(txHash)
    const transactionReceipt = await provider.getTransactionReceipt(txHash)

    return {
      transaction,
      transactionReceipt
    }
  });

  const transactionsResponse = await Promise.all(transactionsRawRequests)

  transactionsResponse.forEach(transactionResponse => {
    const transactionContext: TransactionContext = {
      chainId: ETHEREUM_CHAIN_ID,
      walletAddress: getAddress(testWalletAddress) || '0x' 
    };

    const rawTransaction = {
      ...transactionResponse.transaction,
      receipt: transactionResponse.transactionReceipt
    };

    const decodedTransaction = decodeTransaction(rawTransaction, transactionContext)

    console.log(`Raw transaction ${rawTransaction.hash}`, JSON.stringify(rawTransaction, undefined, 1));
    console.log(`Decoded transaction ${rawTransaction.hash}`, JSON.stringify(decodedTransaction, undefined, 1));
    console.log(' ');
  })
}
Enter fullscreen mode Exit fullscreen mode

Let's examine the object of a USDC token transfer transaction (Transaction page on etherscan) and its corresponding receipt.

If we request this transaction from Ethereum's rpc (eth_getTransactionByHash), we will get the following object.

{
    "jsonrpc":"2.0",
    "id":1,
    "result":{
        "blockHash":"0x93dbccc61ff09a4ba65dacf26c35d2035373aca9453c65f65523ce9cb8700b41",
        "blockNumber":"0x115f5a9",
        "hash":"0xa81b0b764ea32179b29c1098378992bed1b9a53b04f180393f0438d02da1687e",
        "accessList":[

        ],
        "chainId":"0x1",
        "from":"0x4a7c6899cdcb379e284fbfd045462e751da4c7ce",
        "gas":"0x183ac",
        "gasPrice":"0x1e6d855cc",
        "input":"0xa9059cbb000000000000000000000000f1ddf1fc0310cb11f0ca87508207012f4a9cb3360000000000000000000000000000000000000000000000000000000ba43b7400",
        "maxFeePerGas":"0x299ba7bbf",
        "maxPriorityFeePerGas":"0x5f5e100",
        "nonce":"0x318",
        "r":"0x9531ceb792b8a76f4e9851b73979d6633ccdd4379635af8129a7a9a3bd830164",
        "s":"0x2ada43d1f25287bba8a57939112868f271c89ed30bdd8af0956ac2906b6db000",
        "to":"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
        "transactionIndex":"0x67",
        "type":"0x2",
        "v":"0x1",
        "value":"0x0"
    }
}
Enter fullscreen mode Exit fullscreen mode

If we request the receipt for this transaction from Ethereum's rpc (eth_getTransactionReceipt)

{
    "jsonrpc":"2.0",
    "id":1,
    "result":{
        "transactionHash":"0xa81b0b764ea32179b29c1098378992bed1b9a53b04f180393f0438d02da1687e",
        "blockHash":"0x93dbccc61ff09a4ba65dacf26c35d2035373aca9453c65f65523ce9cb8700b41",
        "blockNumber":"0x115f5a9",
        "logs":[
            {
                "transactionHash":"0xa81b0b764ea32179b29c1098378992bed1b9a53b04f180393f0438d02da1687e",
                "address":"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
                "blockHash":"0x93dbccc61ff09a4ba65dacf26c35d2035373aca9453c65f65523ce9cb8700b41",
                "blockNumber":"0x115f5a9",
                "data":"0x0000000000000000000000000000000000000000000000000000000ba43b7400",
                "logIndex":"0x178",
                "removed":false,
                "topics":[
                    "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",
                    "0x0000000000000000000000004a7c6899cdcb379e284fbfd045462e751da4c7ce",
                    "0x000000000000000000000000f1ddf1fc0310cb11f0ca87508207012f4a9cb336"
                ],
                "transactionIndex":"0x67"
            }
        ],
        "contractAddress":null,
        "effectiveGasPrice":"0x1e6d855cc",
        "cumulativeGasUsed":"0x9717a6",
        "from":"0x4a7c6899cdcb379e284fbfd045462e751da4c7ce",
        "gasUsed":"0x10059",
        "logsBloom":"0x00000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000000000000000000000000008000008000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000010000000000008000000000000000000000020000000000000010000000000000000000000000000000000200000000000000000000000000000000000000000000000000020000002000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000000",
        "status":"0x1",
        "to":"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
        "transactionIndex":"0x67",
        "type":"0x2"
    }
}
Enter fullscreen mode Exit fullscreen mode

In decoding logic we will use the properties 'from', 'to', 'value' from the Transaction and 'logs' from the Transaction Receipt.

It's worth noting that for production solutions, the option of sending a separate eth_getTransactionReceipt request to retrieve logs for each transaction is not suitable.

Fortunately, there are powerful blockchain data providers in the market that offer convenient API methods, allowing you to get a list of wallet transactions all at once along with the logs (for example, Covalent).

As we can see nothing about Transaction type, Transaction actions, Transaction participant appears in the raw transaction data. The raw data does not even contain numbers in 10-type form, all data is encoded.

Decoding events from raw transaction logs

We've designed basic types/entities and found a way to get the basic transaction object and its corresponding receipt with logs. Next, it's necessary to decode the event logs.

Function decodeTransactionLogs

The decodeTransactionLogs function is used to decode the logs of a transaction. These logs contain events that were emitted during the execution of the transaction. Decoding the logs can provide valuable information about what happened during the transaction execution.

The decodeTransactionLogs function is available in file services/transactionDecoder/events.ts.

const contractInterfaces = [
    ERC20TokenEventsABIInterface,
    ERC721TokenEventsABIInterface,
    UniswapABIInterface
]

const decodeTransactionLogs = (logs: TransactionLogRaw[]): TransactionLogWithDecodedEvent[] => {
    try {
        return logs
            .map(log => {
                const decodedEvent = decodeLogWithInterface(log);
                return {
                    ...log,
                    decodedEvent
                };
            })
            .filter((log) => !!log.decodedEvent);
    } catch (error) {
        return [];
    }
};

const decodeLogWithInterface = (log: TransactionLogRaw): utils.LogDescription | undefined => {
    for (const contractInterface of contractInterfaces) {
        try {
            const decodedEvent = contractInterface.parseLog(log);
            if (decodedEvent) {
                return decodedEvent;
            }
        } catch (err) {

        }
    }
    return undefined;
};
Enter fullscreen mode Exit fullscreen mode

Here's a detailed breakdown of how the decodeTransactionLogs function works:

  1. Input: The function takes one argument: logs, which is an array of raw transaction logs.
  2. Decoding: The function maps over the logs array and tries to decode each log using the decodeLogWithInterface function. This function uses the ethers.js library and a list of contract interfaces (ABI) to try and parse the log. If a log can be parsed with a contract interface, the parsed log (decoded event) is returned. If a log cannot be parsed with any contract interface, undefined is returned.
  3. Filtering: After all logs have been attempted to be decoded, the function filters out any logs that could not be decoded (i.e., logs for which decodedEvent is undefined). This leaves us with an array of logs that have been successfully decoded.
  4. Output: The function returns the array of decoded logs. Each decoded log is an object that includes the original log and the decoded event.
  5. Error handling: If an error occurs during the execution of the function, it is caught and the function returns an empty array.

The decodeTransactionLogs function is used in the transaction decoding service to decode the logs of a transaction before applying the transaction type rules. By decoding the logs first, the rule functions can work with decoded events instead of raw logs, which makes them simpler and easier to understand.

Then, we'll determine the code that takes the raw transaction with logs, decodes the logs, and, using a set of rules, tries to transform the transaction into a human-readable form and aligns it with a transaction interface with properties such as type, direction, transaction action, etc.

Function decodeTransaction

The decodeTransaction function is a core part of the transaction decoding service. Its purpose is to take a raw Ethereum transaction and decode it into a more human-readable format.

The decodeTransaction function is available in file services/transactionDecoder/transactions.ts.

const transactionTypeRules: TransactionTypeRule[] = [
    erc20DirectTransactionRule,
    nativeTransactionRule,
    erc721TransactionRule,
    executionTransactionRule
];

export const decodeTransaction = (tx: TransactionRaw, transactionContext: TransactionContext): Transaction => {
    const { chainId, walletAddress } = transactionContext;

    const decodedLogs = decodeTransactionLogs(tx.receipt.logs);

    const transactionWithDecodedLogs: TransactionWithDecodedLogs = {
        ...tx,
        decodedLogs: decodedLogs
    };

    try {
        for (const transactionTypeRule of transactionTypeRules) {
            const formattedTransaction = transactionTypeRule(transactionWithDecodedLogs, transactionContext);

            if (formattedTransaction) {
                return formattedTransaction;
            }
        }
    } catch (err) {}

    const defaultTx: Transaction = {
        chainId: chainId,
        fromAddress: tx.from,
        toAddress: tx.to || null,
        value: String(tx.value),
        hash: tx.hash,
        type: TransactionType.EXECUTION,
        status: !!tx.blockNumber ? TransactionStatus.SUCCESS : TransactionStatus.FAILED,
        executed: tx.timestamp?.toString() || '',
        gasFee: tx.gasLimit.toString(),
        direction: tx.from === walletAddress ? TransactionDirection.OUT : TransactionDirection.IN,
        transactionActions: [],
        walletAddress
    };

    if (BigNumber.from(tx.value).gt(0)) {
        defaultTx.transactionActions = [getNativeTransactionTransferAction (tx, transactionContext)];
    }

    return defaultTx;
};
Enter fullscreen mode Exit fullscreen mode

Here's a detailed breakdown of how it works:

  1. Input: The function takes two arguments: rawTransaction and transactionContext. The rawTransaction is the transaction data fetched from the Ethereum blockchain. The transactionContext contains context information such as the wallet address and the chain ID.
  2. Decoding: The function uses the ethers.js library to parse the raw transaction and decode its logs. This gives us a transaction object (tx) with decoded logs and a receipt.
  3. Transaction Type Rules: The function then goes through a list of transaction type rules. These rules are functions that take the decoded transaction and the transaction context as input, and return a decoded transaction if the rule applies, or false if it doesn't. The rules are checked in a specific order, and the function stops at the first rule that applies. For example, one of these rules is erc20DirectTransferTransactionRule, which checks if the transaction is a direct (one-step) transfer of ERC-20 tokens. If it is, the rule function decodes the transaction accordingly and returns the decoded transaction.
  4. Output: If a rule applies, the function returns the decoded transaction returned by the rule function. If no rule applies, the function returns a fallback/default transaction type.

The decodeTransaction function is designed to be extensible. New transaction type rules can be added as needed to support more transaction types. Each rule function is responsible for decoding transactions of a specific type, which keeps the function modular and maintainable.

Function erc20DirectTransferTransactionRule

The erc20DirectTransferTransactionRule function is available in file services/transactionDecoder/transactionTypeRules/erc20DirectTransferTransactionRule.ts.

Let's consider one of the TransactionTypeRule, which, using the heuristic defined in the rule, tries to determine whether it's an ERC-20 token transfer transaction, and if so, it creates the transaction object we need.

A TransactionTypeRule is a function that takes a raw transaction and a transaction context as input, and returns a identified transaction if the rule applies, or false if it doesn't. These rules are used in the decodeTransaction function to determine the type of a transaction and to extract relevant information.

export const erc20DirectTransferTransactionRule: TransactionTypeRule = (tx, transactionContext) => {
    try {
        const { hash, to } = tx;
        const { walletAddress } = transactionContext;

        const erc20TokenTransferEvents = tx.decodedLogs.filter((log) => isErc20TokenTransferEvent(log));

        if (erc20TokenTransferEvents.length !== 1) return false;

        const erc20TokenTransferEvent = erc20TokenTransferEvents[0];
        if (!erc20TokenTransferEvent) return false;

        const transactionTransferAction = mapErc20TokenTransferLogToTransactionTransferAction (erc20TokenTransferEvent, transactionContext);

        if (!transactionTransferAction) {
            return false;
        }

        const condition = erc20TokenTransferEvent && transactionTransferAction;

        if (!condition) {
            return false;
        }

        const fromAddress = getAddress(transactionTransferAction.from.address) || '';

        return {
            chainId: transactionContext.chainId,
            hash: hash,
            fromAddress: tx.from,
            toAddress: tx.to || null,
            value: tx.value.toString(),
            type: fromAddress === walletAddress ? TransactionType.SEND_TOKEN : TransactionType.RECEIVE_TOKEN,
            status: !!tx.blockNumber ? TransactionStatus.SUCCESS : TransactionStatus.FAILED,
            executed: tx.timestamp?.toString() || '',
            fee: tx.gasPrice ? tx.receipt.gasUsed.mul(tx.gasPrice).toString(): '0',
            direction: tx.from === transactionContext.walletAddress ? TransactionDirection.OUT : TransactionDirection.IN,
            transactionActions: [transactionTransferAction].filter(action => !!action),
            walletAddress
        };
    } catch (error) {
        console.error('[erc20DirectTransferTransactionRule]', error);
        return false;
    }
};
Enter fullscreen mode Exit fullscreen mode

The erc20DirectTransferTransactionRule is a specific TransactionTypeRule designed to handle direct (one-step) transfers of ERC-20 tokens. Here's a detailed breakdown of how it works:

  1. Input: The function takes two arguments: tx, which is the transaction to be decoded, and transactionContext, which contains context information such as the wallet address.
  2. Filter ERC-20 token transfer events: The function filters the decoded logs of the transaction to find ERC-20 token transfer events. This is done using the isErc20TokenTransferEvent function.
  3. Check for one-step transfer: The function checks if there is exactly one ERC-20 token transfer event. If not, it returns false, indicating that the rule does not apply to this transaction.
  4. Map ERC-20 token transfer log to transaction action: If there is exactly one ERC-20 token transfer event, the function maps this event to a transaction action using the mapErc20TokenTransferLogToTransactionTransferAction function. This function extracts the relevant information from the event and formats it as a transaction action.
  5. Check for valid transaction action: The function checks if a valid transaction action was created. If not, it returns false.
  6. Create and return transaction object: If a valid transaction action was created, the function creates a transaction object that includes the chain ID, transaction hash, sender and receiver addresses, transaction value, transaction type, transaction status, execution time, transaction fee, transaction direction, and the transaction action. This object is then returned.

The erc20DirectTransferTransactionRule is used in the decodeTransaction function to handle direct transfers of ERC-20 tokens. If a transaction is a direct transfer of ERC-20 tokens, this rule will decode it accordingly and return the decoded transaction. If a transaction is not a direct transfer of ERC-20 tokens, the rule will return false, and the decodeTransaction function will move on to the next rule.

Exploring the results of our data decoding and preparing logic

ERC-20 transfer transaction

Transaction page on Etherscan

The UI we aim to develop

Image description

The transaction object we prepared for UI

{
    "chainId":1,
    "hash":"0xa81b0b764ea32179b29c1098378992bed1b9a53b04f180393f0438d02da1687e",
    "fromAddress":"0x4a7C6899cdcB379e284fBFD045462e751DA4C7cE",
    "toAddress":"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
    "value":"0",
    "type":"SEND_TOKEN",
    "status":"SUCCESS",
    "executed":"",
    "fee":"536018746987500",
    "direction":"OUT",
    "transactionActions":[
        {
            "type":"TRANSFER",
            "token":{
                "chainId":1,
                "type":"ERC-20",
                "name":"USDC",
                "iconUrl":"",
                "address":"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
                "symbol":"USDC",
                "decimals":6
            },
            "value":"50000000000",
            "from":{
                "type":"UNKNOWN",
                "address":"0x4a7C6899cdcB379e284fBFD045462e751DA4C7cE"
            },
            "to":{
                "type":"UNKNOWN",
                "address":"0xf1DdF1fc0310Cb11F0Ca87508207012F4a9CB336"
            },
            "direction":"OUT"
        }
    ],
    "walletAddress":"0x4a7C6899cdcB379e284fBFD045462e751DA4C7cE"
}
Enter fullscreen mode Exit fullscreen mode

As we can see our transaction object has everything necessary to realize such UI.

ERC-20 token exchange/swap transaction

Transaction page on Etherscan

The UI we aim to develop

Image description

The transaction object we prepared for UI

{
    "chainId":1,
    "hash":"0x49dc2be71db900f5699656f536fbcd606353015bcb4420985aa55985fa18e0d5",
    "fromAddress":"0x4a7C6899cdcB379e284fBFD045462e751DA4C7cE",
    "toAddress":"0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD",
    "value":"0",
    "type":"SWAP",
    "status":"SUCCESS",
    "executed":"",
    "fee":"2378243995433416",
    "direction":"OUT",
    "transactionActions":[
        {
            "type":"SWAP",
            "trader":{
                "type":"EXTERNAL",
                "address":"0x4a7C6899cdcB379e284fBFD045462e751DA4C7cE"
            },
            "application":{
                "type":"CONTRACT",
                "address":"0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc"
            }
        },
        {
            "type":"TRANSFER",
            "token":{
                "chainId":1,
                "type":"ERC-20",
                "name":"FRENBOT",
                "iconUrl":"",
                "address":"0xCA5001bC5134302Dbe0F798a2d0b95Ef3cF0803F",
                "symbol":"MEF",
                "decimals":18
            },
            "value":"403597500532145231683",
            "from":{
                "type":"UNKNOWN",
                "address":"0x4a7C6899cdcB379e284fBFD045462e751DA4C7cE"
            },
            "to":{
                "type":"UNKNOWN",
                "address":"0xCA5001bC5134302Dbe0F798a2d0b95Ef3cF0803F"
            },
            "direction":"OUT"
        },
        {
            "type":"TRANSFER",
            "token":{
                "chainId":1,
                "type":"ERC-20",
                "name":"FRENBOT",
                "iconUrl":"",
                "address":"0xCA5001bC5134302Dbe0F798a2d0b95Ef3cF0803F",
                "symbol":"MEF",
                "decimals":18
            },
            "value":"7668352510110759401990",
            "from":{
                "type":"UNKNOWN",
                "address":"0x4a7C6899cdcB379e284fBFD045462e751DA4C7cE"
            },
            "to":{
                "type":"UNKNOWN",
                "address":"0xb181f381773d7C732B8Af4e39B39dCdb8380196a"
            },
            "direction":"OUT"
        },
        {
            "type":"TRANSFER",
            "token":{
                "chainId":1,
                "type":"ERC-20",
                "name":"USDC",
                "iconUrl":"",
                "address":"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
                "symbol":"USDC",
                "decimals":6
            },
            "value":"2520347185",
            "from":{
                "type":"UNKNOWN",
                "address":"0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc"
            },
            "to":{
                "type":"UNKNOWN",
                "address":"0x4a7C6899cdcB379e284fBFD045462e751DA4C7cE"
            },
            "direction":"IN"
        }
    ],
    "walletAddress":"0x4a7C6899cdcB379e284fBFD045462e751DA4C7cE"
}
Enter fullscreen mode Exit fullscreen mode

As we can see our transaction object has everything necessary to realize such UI.

More examples of decoded transactions are available in file decoded_transactions_results.md.

Summary

In this article, I've taken a deep dive into the fascinating world of decoding and visualizing cryptocurrency wallet transactions. We began our journey by understanding the crucial role of event logs in blockchain transactions, appreciating how they serve as indicators of actions and sources of valuable on-chain data.

The article guided us through the inner workings of how events are stored in logs, emphasizing the significance of contract ABIs and the utility of libraries like Ethers.js in deciphering event data. These foundational concepts paved the way for our exploration of transaction visualization.

We explored real-world examples of how leading web3 products such as Etherscan, Zerion, Zapper, Interface app, and Rainbow wallet effectively visualize wallet activity. These platforms share a common strategy for data preparation, where they enhance events with supplementary information and construct well-organized transaction histories.

The core of the article delved into the technical intricacies of coding transaction decoding and data preparation logic, inspired by Zerion's approach. We broke down transactions into their constituent parts, designed data structures, and illustrated how to fetch raw transaction data. Additionally, we discussed the critical process of decoding events from raw transaction logs, including a specific rule for handling ERC-20 token transfer transactions.

To demonstrate the practicality of our data preparation logic, we provided concrete examples of decoded transactions, spanning both ERC-20 token transfers and token exchange/swap operations. These decoded transactions closely resembled the user interfaces presented in the article, showcasing the effectiveness and completeness of our data preparation approach.

Top comments (0)