Skip to main content
All articles

From Blockchain Transactions to a Familiar Database

· 12 min read
Bankware Global Engineering

A token transfer succeeded. You have also checked its transaction receipt on a blockchain node. Yet the business application still shows no transfer record. Did the transaction fail, or is the display behind?

If the application queries a separate database instead of reading the blockchain directly, data must be moved and interpreted along the way. Execution on the chain and the appearance of a queryable database row happen at different times. Understanding that gap helps avoid sending the tokens again just because the record is missing from the screen.

BXB's block mirror handles this connection. Using a transfer of 10 tokens as an example, let's follow a chain log into a familiar table and consider what that table needs to guarantee.

Choose the event to record from a successful transaction​

On an Ethereum-compatible EVM network N, A holds 100 units of token T, and B holds none. Suppose transaction H sends 10 T from A to B, is included in block 501, and succeeds. The resulting balances are 90 T for A and 10 T for B.

T has 6 decimal places. There is no minting, burning, token transfer fee, or other concurrent transaction. A separately holds the native coin needed to pay network fees. N, H, A, and B are illustrative names. This example is not a reproduction of a production transaction.

A receipt records the result of a transaction's execution. The EVM's eth_getTransactionReceipt looks up a receipt by transaction hash and returns the execution status, block information, and logs emitted during execution, among other details. A successful receipt alone does not tell us which asset moved or how much. Those details must be interpreted according to the contract's rules.

A standard ERC-20 token emits an event of the following form when a transfer occurs.

event Transfer(
address indexed from,
address indexed to,
uint256 value
);

An event is a structured record emitted during contract execution. In this example, T emits a Transfer stating that a raw amount of 10000000 moved from A to B. A transaction is a unit of execution; an event records something that happened within it. A transaction that calls several contracts or makes several transfers can produce multiple logs.

After finding H, the collector therefore needs to select the expected event emitted by token T. Mixing in events with the same name from other contracts, or always treating one transaction as one transfer, can distort the business records.

Reading a log means knowing where its addresses and numbers are​

A log returned by RPC does not directly contain the familiar fields from, to, and value. Instead, it has the emitting contract's address, searchable topics, and data holding the remaining values.

The ABI provides the rules for encoding contract functions and events as bytes and interpreting them again. Under the Solidity ABI event rules, the first topic of an ordinary event such as the one above holds a signature hash derived from the event name and parameter types. Here, it is the hash of Transfer(address,address,uint256). The two addresses marked indexed occupy the following topics; the amount, which is not indexed, goes in data.

Log fieldMeaning in this example
addressToken T's emitting contract address
topics[0]Event signature hash
topics[1]Sender A
topics[2]Recipient B
dataRaw amount 10000000

The log's address is T's address, since T emitted the event. It is not recipient B's address. An asset must be identified by both its contract address and its network. Two tokens cannot be treated as the same asset simply because they share a display name.

Decoding an address takes another step. An indexed address contains a 20-byte address padded to fit a 32-byte slot. To store it as an address for queries, we need to interpret it according to its ABI type and choose a consistent representation for comparisons. Simply adding 0x does not complete that conversion.

Nor should every log with the same first topic be interpreted as a transfer of the same asset. We need to specify the supported contracts and event ABIs, and check the number of topics and the types encoded in data. The table above describes the meaning of decoded values. It is not a table of execution results copied from BXB's stored data.

Why store 10000000 and display 10?​

The chain handles this token's amounts as integers. The decimal point people see comes from applying the token's display rules. When ERC-20's decimals is 6, the displayed amount is the raw amount divided by 10 to the power of 6, or 1000000.

Raw balances before transfer
A: 100000000 / B: 0

Raw amount transferred: 10000000
↓
Raw balances after transfer
A: 90000000 / B: 10000000

Transfer amount for display
10000000 ÷ 1000000 = 10 T

The database can preserve the raw integers used for calculations and apply each token's decimal places when displaying them. Converting to floating-point numbers at the outset can lose precision for large integers or small units. The database column holding the raw integer also needs enough precision for the range of values it supports.

BXB's transfer log entity defines the amount as a BigDecimal with a scale of 0. The event handler also reads the hexadecimal value in the log as an integer, without scaling it down using decimals. The admin API serializes this amount as a string, and the admin screen displays the amount it receives. This path should not be read as already combining token metadata with the amount to turn it into 10 T.

Adding that display feature would require metadata associated with the network and contract address. Since decimals is optional in ERC-20, we cannot assume every token supplies it or uses the same value. If the metadata has not yet been confirmed, labeling the value as a raw amount is more accurate than placing a decimal point arbitrarily.

BXB separates block retrieval from event storage​

The BXB mirror source separates responsibilities as follows.

Compare saved position
with current block height
↓
Fetch next block's tx hashes
↓
Pass to an in-process buffer
↓
Fetch each transaction's receipt
↓
Decode supported events
and call storage
↓
Query DB rows via the admin API

MirrorProducer, acting as the producer, checks the block height on a configured node and reads the next block. It passes that block's transaction hashes to an in-process event buffer called Disruptor. MirrorConsumer, acting as the consumer, retrieves each receipt by hash and passes its logs to the relevant event handler. TransferEventHandler has a path that extracts addresses, amounts, and other values from Transfer logs and passes them to the repository.

This arrangement places storage between collection, decoding, and queries, rather than passing raw data directly to the business application. The admin backend filters block_mirror_tx_log by network and queries it in descending order of block number and log index. Reading the list and detail views does not require signing with a wallet or sending a new transaction each time.

The following information matters when representing our event as a queryable row. Log index 7 is an example. It is the log's index within the block, not its position in the receipt's array.

Information to recordExample value
NetworkN
Transaction hashH
Log index7
Block number501
ContractT's address
Sender / recipientA / B
Raw amount10000000

The actual entity uses the network ID, transaction hash, and log index as a composite key. This distinguishes separate events within one transaction and provides a basis for recognizing an event that has been read before. Having a key does not, by itself, guarantee safe reprocessing throughout the pipeline. We also need to consider database writes and collection progress.

So far, we have described the structure found in the source and the rules for interpreting the data. This is not a hands-on procedure verified by running the full path from address conversion through database storage and queries.

How far you have read is not how far you have stored​

Suppose block 501 contains transactions H1, H2, and H3. The producer has passed all three hashes to the buffer, and the consumers have processed H1 and H3, but not H2 yet. At this point, “read through block 501” and “stored every required event through block 501” mean different things.

BXB's producer updates the block number marking collection progress after passing the transaction hashes to the buffer. That update does not wait for all consumers to finish writing to the database. Using this number directly as the completion point for business query data would therefore count events still being processed as complete. Passing data to an in-process buffer is also different from recording it durably in a database.

To explain how complete the query results are, we need to distinguish at least three points.

Point reachedWhat it tells us
Chain result observedThe node returned the execution result
Collection work handed offThe block's transactions were passed on for processing
Required DB writes completedQuery data for that range has been stored

When designing ingestion that can recover from interruptions, we can define the point to resume reading separately from the point through which database writes are complete. For example, block 501 would not be marked fully complete until H2 had been processed, and a restart would reread the unfinished range. A transaction with no supported events still counts toward completion once we have checked it and found nothing to store. Simply comparing the number of stored rows with the number of transactions in a block cannot establish completeness.

Reprocessing may read the same event more than once. The network, hash, and log index help identify duplicates, but the database's behavior on duplicate input and the update of the completion position need to be designed together. For example, even if processing stops after a row is stored but before progress is recorded, the next collection pass should converge on a consistent result. These are design requirements for business queries, not a claim that the current mirror already prevents all omissions and provides exactly-once processing.

Stored rows must still be reconciled with chain history​

A block number gives a height; it does not uniquely identify the contents of that block. During a chain reorganization, or reorg, the block read at height 501 can be replaced by another block. H's logs, as initially observed, may disappear from the selected chain history or appear at a different position.

Leaving the existing rows untouched would make the application show a past that differs from the current chain. Checking the same block height again is not enough to detect the difference. We need the source block's hash and information that lets us check the continuity of the history. We also need to decide how to invalidate or recalculate affected rows and what level of confirmation is sufficient to consider the business operation complete.

Ethereum JSON-RPC's log filter change responses can identify logs removed by a reorganization through the removed field. This is one example of how changes to chain history need to be tracked. The path examined in BXB reads receipts by transaction; it is different from a path that uses this filter interface to handle removed logs.

The mirror's transfer log entity has a block number, but no fields for tracking the source block hash or whether a log has been removed. This is why the current composite key alone cannot be described as handling chain reorganizations. Using the query database for operations requires rules for reconciling it with the selected chain history, as well as for resuming collection.

Node connectivity needs to be viewed in the same light. BXB has a path that tries another configured node when a request fails. This helps improve connectivity, but being able to connect to several nodes does not guarantee that all of them show the same height and confirmation state, or that no data is missed during ingestion.

Choose what to repeat when the query result is late​

Return to the original example. Once 10 T has moved on the chain and the mirror has correctly decoded the event and stored it as a row, the application can use that row and the token's display rules to show “10 T from A to B.” The chain handles execution and event recording; the database provides a representation suited to business queries.

If the row is missing from the screen, check the transaction's execution result, collection progress, event decoding, and query conditions separately. Rereading an event that has not yet been collected has a very different effect from executing the token transfer again. As discussed in the article on transfer timeouts and retries, repeating an already successful transaction as a new transfer can leave A with 80 T and B with 20 T. Filling a gap in the database history must not move the assets a second time.

The mirror database is a separate representation that makes queries easier. A row does not replace chain consensus or cryptographic proof. When a business system tracks which event on which network a row came from, how far the data has been reflected in the database, and how to reconcile it when history changes, it can use that representation as a grounded source for queries.