Skip to main content

EVM Connectivity

In Part 1 and Part 2 of the series, you you have learned how the Data Connector works and what kind of different attestations you can get from it. In this guide, you will:

  • Move from the world of UTXO chains to the world of EVM chains with a new EVMTransaction attestation type.
  • Understand the implications of connecting account-based chains, and the additional possibilities that smart contracts bring.
  • Connect Ethereum and Flare (or testnets Sepolia and Flare Testnet Coston2 for the Coston testnet).

From Parts 1 and 2, the attestations that you know so far are:

  • Simple payment

  • Non-existence of a payment with reference

  • Balance decreasing transaction

  • Block height confirmation

  • Address validity check

You also know that the Data Connector allows Flare to connect to Bitcoin, Dogecoin, and XRP Ledger.

The information that the Data Connector provides is similar to what was provided before (sender and recipient, amount, block, timestamp, etc.), but since you are on a smart contract compatible chain now, you can also get additional things, namely, you can extract the full data about events that were emitted during the transaction, and you can also get the input data of the transaction (in case a contract was called).

Transaction Type

Let's jump directly into the transaction type to see what kind of data we need to provide.

The top-level Request in the EVMTransaction has the same structure as others:

EVMTransaction.sol
// SPDX-License-Identifier: MIT
pragma solidity >=0.7.6 <0.9;

/**
* @custom:name EVMTransaction
* @custom:id 0x06
* @custom:supported ETH, FLR, SGB, testETH, testFLR, testSGB
* @author Flare
* @notice A relay of a transaction from an EVM chain.
* This type is only relevant for EVM-compatible chains.
* @custom:verification If a transaction with the `transactionId` is in a block on the main branch with at least `requiredConfirmations`, the specified data is relayed.
* If an indicated event does not exist, the request is rejected.
* @custom:lut `timestamp`
*/
interface EVMTransaction {
/**
* @notice Toplevel request
* @param attestationType ID of the attestation type.
* @param sourceId ID of the data source.
* @param messageIntegrityCode `MessageIntegrityCode` that is derived from the expected response.
* @param requestBody Data defining the request. Type (struct) and interpretation is determined by the `attestationType`.
*/
struct Request {
bytes32 attestationType;
bytes32 sourceId;
bytes32 messageIntegrityCode;
RequestBody requestBody;
}

/**
* @notice Toplevel response
* @param attestationType Extracted from the request.
* @param sourceId Extracted from the request.
* @param votingRound The ID of the Data Connector round in which the request was considered.
* @param lowestUsedTimestamp The lowest timestamp used to generate the response.
* @param requestBody Extracted from the request.
* @param responseBody Data defining the response. The verification rules for the construction of the response body and the type are defined per specific `attestationType`.
*/
struct Response {
bytes32 attestationType;
bytes32 sourceId;
uint64 votingRound;
uint64 lowestUsedTimestamp;
RequestBody requestBody;
ResponseBody responseBody;
}

/**
* @notice Toplevel proof
* @param merkleProof Merkle proof corresponding to the attestation response.
* @param data Attestation response.
*/
struct Proof {
bytes32[] merkleProof;
Response data;
}

/**
* @notice Request body for EVM transaction attestation type
* @custom:below Note that events (logs) are indexed in block not in each transaction. The contract that uses the attestation should specify the order of event logs as needed and the requestor should sort `logIndices`
* with respect to the set specifications. If possible, the contact should only require one `logIndex`.
* @param transactionHash Hash of the transaction(transactionHash).
* @param requiredConfirmations The height at which a block is considered confirmed by the requestor.
* @param provideInput If true, "input" field is included in the response.
* @param listEvents If true, events indicated by `logIndices` are included in the response. Otherwise, no events are included in the response.
* @param logIndices If `listEvents` is `false`, this should be an empty list, otherwise, the request is rejected. If `listEvents` is `true`, this is the list of indices (logIndex) of the events to be relayed (sorted by the requestor). The array should contain at most 50 indices. If empty, it indicates all events in order capped by 50.
*/
struct RequestBody {
bytes32 transactionHash;
uint16 requiredConfirmations;
bool provideInput;
bool listEvents;
uint32[] logIndices;
}

/**
* @notice Response body for EVM transaction attestation type
* @custom:below The fields are in line with [transaction](https://ethereum.org/en/developers/docs/apis/json-rpc/#eth_gettransactionbyhash) provided by EVM node.
* @param blockNumber Number of the block in which the transaction is included.
* @param timestamp Timestamp of the block in which the transaction is included.
* @param sourceAddress The address (from) that signed the transaction.
* @param isDeployment Indicate whether it is a contract creation transaction.
* @param receivingAddress The address (to) of the receiver of the initial transaction. Zero address if `isDeployment` is `true`.
* @param value The value transferred by the initial transaction in wei.
* @param input If `provideInput`, this is the data send along with the initial transaction. Otherwise it is the default value `0x00`.
* @param status Status of the transaction 1 - success, 0 - failure.
* @param events If `listEvents` is `true`, an array of the requested events. Sorted by the logIndex in the same order as `logIndices`. Otherwise, an empty array.
*/
struct ResponseBody {
uint64 blockNumber;
uint64 timestamp;
address sourceAddress;
bool isDeployment;
address receivingAddress;
uint256 value;
bytes input;
uint8 status;
Event[] events;
}

/**
* @notice Event log record
* @custom:above An `Event` is a struct with the following fields:
* @custom:below The fields are in line with [EVM event logs](https://ethereum.org/en/developers/docs/apis/json-rpc/#eth_getfilterchanges).
* @param logIndex The consecutive number of the event in block.
* @param emitterAddress The address of the contract that emitted the event.
* @param topics An array of up to four 32-byte strings of indexed log arguments.
* @param data Concatenated 32-byte strings of non-indexed log arguments. At least 32 bytes long.
* @param removed It is `true` if the log was removed due to a chain reorganization and `false` if it is a valid log.
*/
struct Event {
uint32 logIndex;
address emitterAddress;
bytes32[] topics;
bytes data;
bool removed;
}
}

The attestationType for the evm attestation is a hex encoding of hexEncode("EVMTransaction")

Request Body

The RequestBody is defined as:

    /**
* @notice Request body for EVM transaction attestation type
* @custom:below Note that events (logs) are indexed in block not in each transaction. The contract that uses the attestation should specify the order of event logs as needed and the requestor should sort `logIndices`
* with respect to the set specifications. If possible, the contact should only require one `logIndex`.
* @param transactionHash Hash of the transaction(transactionHash).
* @param requiredConfirmations The height at which a block is considered confirmed by the requestor.
* @param provideInput If true, "input" field is included in the response.
* @param listEvents If true, events indicated by `logIndices` are included in the response. Otherwise, no events are included in the response.
* @param logIndices If `listEvents` is `false`, this should be an empty list, otherwise, the request is rejected. If `listEvents` is `true`, this is the list of indices (logIndex) of the events to be relayed (sorted by the requestor). The array should contain at most 50 indices. If empty, it indicates all events in order capped by 50.
*/
struct RequestBody {
bytes32 transactionHash;
uint16 requiredConfirmations;
bool provideInput;
bool listEvents;
uint32[] logIndices;
}
  • TransactionHash: Hash of the transaction you are observing.

  • RequiredConfirmations: The number of blocks after the transaction that you are requesting must be visible to the attestation client to consider this transaction as finalized. Unlike the previous payment (or block height) attestation, where the amount of block confirmations was set per chain, this type is more flexible and allows you to choose how many confirmations you want, thus adapting your security assumptions (about the other chain).

  • provideInput: Indicates if the response should also contain the input of the transaction. You can always include the input, but this might produce a large data structure that you will need to supply when using this transaction. If you don't need this data, it is advisable not to include it to avoid additional gas costs both for supplying it to the verification contract and making a transaction. However, it might be useful, for example, to check what contract was deployed or what was the top-level method that was executed.

  • listEvents: Events are an important and powerful tool when interacting with EVM chains, but including them adds additional costs (the same as with input). If you don't need events, you can save some gas costs by excluding them.

  • logIndices: An array of log indices (in any order, with repetitions allowed) for which events (logs) you want included as the result of your transaction attestation. As before, don't include events you don't need for gas reasons. Importantly, leaving this array empty will include all events emitted in the same order as they were emitted. The indices are the block log indices, indicating the event index in the whole block (not just the transactions you are attesting to), but if you supply an index outside your transaction range, the corresponding event won't be included. The amount of returned events is limited to 50, so if you want to attest that you have included all the events in a single transaction, make sure it has 49 of them or less.

Response Body

The ResponseBody is defined as:

    /**
* @notice Response body for EVM transaction attestation type
* @custom:below The fields are in line with [transaction](https://ethereum.org/en/developers/docs/apis/json-rpc/#eth_gettransactionbyhash) provided by EVM node.
* @param blockNumber Number of the block in which the transaction is included.
* @param timestamp Timestamp of the block in which the transaction is included.
* @param sourceAddress The address (from) that signed the transaction.
* @param isDeployment Indicate whether it is a contract creation transaction.
* @param receivingAddress The address (to) of the receiver of the initial transaction. Zero address if `isDeployment` is `true`.
* @param value The value transferred by the initial transaction in wei.
* @param input If `provideInput`, this is the data send along with the initial transaction. Otherwise it is the default value `0x00`.
* @param status Status of the transaction 1 - success, 0 - failure.
* @param events If `listEvents` is `true`, an array of the requested events. Sorted by the logIndex in the same order as `logIndices`. Otherwise, an empty array.
*/
struct ResponseBody {
uint64 blockNumber;
uint64 timestamp;
address sourceAddress;
bool isDeployment;
address receivingAddress;
uint256 value;
bytes input;
uint8 status;
Event[] events;
}

/**
* @notice Event log record
* @custom:above An `Event` is a struct with the following fields:
* @custom:below The fields are in line with [EVM event logs](https://ethereum.org/en/developers/docs/apis/json-rpc/#eth_getfilterchanges).
* @param logIndex The consecutive number of the event in block.
* @param emitterAddress The address of the contract that emitted the event.
* @param topics An array of up to four 32-byte strings of indexed log arguments.
* @param data Concatenated 32-byte strings of non-indexed log arguments. At least 32 bytes long.
* @param removed It is `true` if the log was removed due to a chain reorganization and `false` if it is a valid log.
*/
struct Event {
uint32 logIndex;
address emitterAddress;
bytes32[] topics;
bytes data;
bool removed;
}

The response body struct contains the following fields:

  • blockNumber: Number of the block in which the transaction is included.

  • timestamp: Timestamp of the block the transaction was included in.

  • sourceAddress: Address signing the transaction. Since Flare is an EVM chain, this is nicely mapped to the address type directly, and you don't have to operate with strings or address hashes.

  • isDeployment: Flag indicating if this transaction was a contract deployment.

  • receivingAddress: The to address of the transaction (this is a zero address if you are dealing with contract deployment). Keep in mind, this can also be a contract address (if the toplevel transaction is a contract call) and this is where things get interesting.

  • value: The value (in wei) transferred by the toplevel transaction. Values transferred by internal transactions are not tracked by this type, but if proper events are emitted you can use them to follow this. If there is no value, the value has a default 0 value.

  • input: The input provided with a transaction (useful for contract calls). If no input is provided, a default value of zero bytes is used.

  • status: The status of the transaction, which can either be 1 indicating success or 0 indicating failure (without failure reason).

  • events: Array of requested events in the same order as requested.

Each event has the following fields:

  • logIndex: The consecutive number of the event in the block.

  • emitterAddress: The address of the contract that emitted the event.

  • topics: An array of up to four 32-byte strings of indexed log arguments.

  • data: Concatenated 32-byte strings of non-indexed log arguments. This (together with topics) is usually the part of an event that you will decode to get the information you need. Keep in mind, this is event-specific and you will need to know the event structure to decode it properly.

  • removed: It is true if the log was removed due to a chain reorganization (transaction was mined, but the block was not on the main chain) and false if it is a valid log.

Examples

Now that you know how to request an attestation and what you are getting in return, let's explore some examples. These examples are a bit more involved and each will come in a few parts:

  • Script making a dummy transaction on the Sepolia testnet.
  • Smart contract(s) accepting an attestation request and performing some desired action.
  • Deployment and run script that ties them together.

This deployment script will also allow you to understand exactly how long the waiting for each phase takes, which is something not previously focused on.

Simple transaction with a value

Let's start small. You will create a smart contract that just tallies the top-level amounts transferred to a designated address on Sepolia.

The scenario is pretty simple:

  • You have a "payment" to an Externally Owned Account (EOA - so not a smart contract) on Sepolia, and anyone can send funds there and prove this.
  • On the Flare side, you will deploy a contract that will accept proofs with data in the proper accounting format: who has sent how much to this end owner address.

The full code for this example is in the scripts/evm/trySimpleTransaction.ts, contracts/EthereumPaymentCollector.sol, and contracts/FallbackContract files.

You won't be copy-pasting the full code here, but you will go through the most important parts.

The setup is now in two parts, and main correctly picks up the right part to run depending on the network it is run on.

First, deploy a simple FallbackContract on Sepolia.

yarn hardhat run scripts/evm/trySimpleTransaction --network sepolia
  • This contract will just emit an event when the fallback function is called.
  • You will be attesting to this event in the next part.
  • The script makes two transactions on Sepolia: one with value to an address and one to the address of the contract.
  • The second transaction will call the fallback function and emit the event.
  • The transaction hashes are logged, and the JSON response of the attestation results is printed (so you can see what you will get in the next part).

Here is an example result:

0xac640ab047aa1097ddd473e5940921eb500a9912b33072b8532617692428830e
{
"status": "VALID",
"response": {
"attestationType": "0x45564d5472616e73616374696f6e000000000000000000000000000000000000",
"sourceId": "0x7465737445544800000000000000000000000000000000000000000000000000",
"votingRound": "0",
"lowestUsedTimestamp": "1708907688",
"requestBody": {
"transactionHash": "0xac640ab047aa1097ddd473e5940921eb500a9912b33072b8532617692428830e",
"requiredConfirmations": "1",
"provideInput": true,
"listEvents": true,
"logIndices": []
},
"responseBody": {
"blockNumber": "5363670",
"timestamp": "1708907688",
"sourceAddress": "0x4C3dFaFc3207Eabb7dc8A6ab01Eb142C8655F373",
"isDeployment": false,
"receivingAddress": "0xFf02F742106B8a25C26e65C1f0d66BEC3C90d429",
"value": "10",
"input": "0x0123456789",
"status": "1",
"events": []
}
}
}
0x7eb54cde238fc700be31c98af7e4df8c4fc96fd5c634c490183ca612a481efcc
{
"status": "VALID",
"response": {
"attestationType": "0x45564d5472616e73616374696f6e000000000000000000000000000000000000",
"sourceId": "0x7465737445544800000000000000000000000000000000000000000000000000",
"votingRound": "0",
"lowestUsedTimestamp": "1708907712",
"requestBody": {
"transactionHash": "0x7eb54cde238fc700be31c98af7e4df8c4fc96fd5c634c490183ca612a481efcc",
"requiredConfirmations": "1",
"provideInput": true,
"listEvents": true,
"logIndices": []
},
"responseBody": {
"blockNumber": "5363672",
"timestamp": "1708907712",
"sourceAddress": "0x4C3dFaFc3207Eabb7dc8A6ab01Eb142C8655F373",
"isDeployment": false,
"receivingAddress": "0xeBBf567beDe2D8842dF538Cf64E0bE9976183853",
"value": "10",
"input": "0x9876543210",
"status": "1",
"events": [
{
"logIndex": "160",
"emitterAddress": "0xeBBf567beDe2D8842dF538Cf64E0bE9976183853",
"topics": [
"0xaca09dd456ca888dccf8cc966e382e6e3042bb7e4d2d7815015f844edeafce42"
],
"data": "0x0000000000000000000000004c3dfafc3207eabb7dc8a6ab01eb142c8655f373000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000059876543210000000000000000000000000000000000000000000000000000000",
"removed": false
}
]
}
}
}

After you have the transaction hashes, copy them to the part of the main method that will execute the Data Connector part, this time on Coston. Let's take a look at executeStateConnectorProof.

Here, the Data Connector part comes into play. You have already seen it in the previous guides, so you will just quickly scan through it. The code is a bit more involved, as you are now working with multiple transactions (this is not EVMTransaction specific, but it is a good example of how you can use the Data Connector to do more complex things). Again, you get an encoded attestation request (one for each transaction) and then you submit them to the Data Connector. Once this is done, you wait for the round to be confirmed (see the while loop that takes most of the time) and then you get the proof.

The EthereumPaymentCollector contract is deployed on Coston with one important method collectPayment. This method accepts the EVMTransaction.Proof response and does the important accounting.

As usual, first check that the provided proof is correct: that the Merkle proof really attests that this transaction was included in the Merkle tree.

Then comes the fun part - you can use the information from a transaction to do whatever you want. You won't just write it to the list of all transactions and be done. Instead, you will try to decode the event data and see what you can get from it. As mentioned before, the event data is specific to the event and you need to know the event structure to decode it properly. In this case, you know how it looks, and the decoding is done by the built-in abi.decode. You then just push the decoded data in struct form to the list of events and you are done.

warning

abi.decode is not type-safe and you can easily get wrong results if you don't know the event structure. Even more, this might be a security risk if you are not careful (or revert unexpectedly), but it is a nice representation of how powerful the events - and their information - can be.

Finally, when you have both proofs and the contract deployed, you just call the collectPayment method with the proofs, and you are done (unless something goes wrong, then you will have to wait for the next round and try again).

The result looks something like:

Rounds:  [ '809307', '809307' ]
Waiting for the round to be confirmed 809303n 809307
Waiting for the round to be confirmed 809303n 809307
Waiting for the round to be confirmed 809303n 809307
Waiting for the round to be confirmed 809304n 809307
Waiting for the round to be confirmed 809304n 809307
Waiting for the round to be confirmed 809304n 809307
Waiting for the round to be confirmed 809304n 809307
Waiting for the round to be confirmed 809304n 809307
Waiting for the round to be confirmed 809305n 809307
Waiting for the round to be confirmed 809305n 809307
Waiting for the round to be confirmed 809305n 809307
Waiting for the round to be confirmed 809305n 809307
Waiting for the round to be confirmed 809306n 809307
Waiting for the round to be confirmed 809306n 809307
Waiting for the round to be confirmed 809306n 809307
Waiting for the round to be confirmed 809306n 809307
Waiting for the round to be confirmed 809306n 809307
Round confirmed, getting proof
Successfully submitted source code for contract
contracts/EthereumPaymentCollector.sol:EthereumPaymentCollector at 0x7cf6E7aeFD0207a5bE9a7DbcDA560fc7a6dBD7B4
for verification on the block explorer. Waiting for verification result...

Successfully verified contract EthereumPaymentCollector on the block explorer.
https://coston-explorer.flare.network/address/0x7cf6E7aeFD0207a5bE9a7DbcDA560fc7a6dBD7B4#code

{
"data": {
"attestationType": "0x45564d5472616e73616374696f6e000000000000000000000000000000000000",
"lowestUsedTimestamp": "1708907688",
"requestBody": {
"listEvents": true,
"logIndices": [],
"provideInput": true,
"requiredConfirmations": "1",
"transactionHash": "0xac640ab047aa1097ddd473e5940921eb500a9912b33072b8532617692428830e"
},
"responseBody": {
"blockNumber": "5363670",
"events": [],
"input": "0x0123456789",
"isDeployment": false,
"receivingAddress": "0xFf02F742106B8a25C26e65C1f0d66BEC3C90d429",
"sourceAddress": "0x4C3dFaFc3207Eabb7dc8A6ab01Eb142C8655F373",
"status": "1",
"timestamp": "1708907688",
"value": "10"
},
"sourceId": "0x7465737445544800000000000000000000000000000000000000000000000000",
"votingRound": "809307"
},
"merkleProof": [
"0x56faf895bbcb0b2a6f3bc283ea5e1793b224dca8b4b99240a34cee6d9bf1b8f3",
"0x13ef0de709e7b0485f7623f5a0ad5b56aa23626fbffe5e7f4502bb7be5e0bf7e",
"0xf72c31824174676516a9c5d9713cb1ae8866cac71462fe2b1a3c1e1b9418a94f"
]
}
{
"data": {
"attestationType": "0x45564d5472616e73616374696f6e000000000000000000000000000000000000",
"lowestUsedTimestamp": "1708907712",
"requestBody": {
"listEvents": true,
"logIndices": [],
"provideInput": true,
"requiredConfirmations": "1",
"transactionHash": "0x7eb54cde238fc700be31c98af7e4df8c4fc96fd5c634c490183ca612a481efcc"
},
"responseBody": {
"blockNumber": "5363672",
"events": [
{
"data": "0x0000000000000000000000004c3dfafc3207eabb7dc8a6ab01eb142c8655f373000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000059876543210000000000000000000000000000000000000000000000000000000",
"emitterAddress": "0xeBBf567beDe2D8842dF538Cf64E0bE9976183853",
"logIndex": "160",
"removed": false,
"topics": [
"0xaca09dd456ca888dccf8cc966e382e6e3042bb7e4d2d7815015f844edeafce42"
]
}
],
"input": "0x9876543210",
"isDeployment": false,
"receivingAddress": "0xeBBf567beDe2D8842dF538Cf64E0bE9976183853",
"sourceAddress": "0x4C3dFaFc3207Eabb7dc8A6ab01Eb142C8655F373",
"status": "1",
"timestamp": "1708907712",
"value": "10"
},
"sourceId": "0x7465737445544800000000000000000000000000000000000000000000000000",
"votingRound": "809307"
},
"merkleProof": [
"0x8e45d2d564bf7d652cf904a72e53f5e7e34d7e5e184906afda92f755e99cd421",
"0x13ef0de709e7b0485f7623f5a0ad5b56aa23626fbffe5e7f4502bb7be5e0bf7e",
"0xf72c31824174676516a9c5d9713cb1ae8866cac71462fe2b1a3c1e1b9418a94f"
]
}
info

On the previous attestation types, we were only able to get transactions in the last two days (this is attestation type specific).

Decoding emitted events

As previously stated, an event will be the core feature for observing what is happening on other chains. Let's now use this to prove that an ERC20 payment was made on Sepolia and then decode the event to see who made the payment and how much.

As before, you will deploy an ERC20 contract on Sepolia, mint some tokens, and send them to an address. The full code is available in the scripts/evm/tryERC20transfers.ts and contracts/MintableERC20.sol files.

A sample response for the ERC20 transaction would look like:

Sepolia USDT deployed to: 0x6023e19d70C304eA16a3728ceDcb042791737EC3
0xd7eed8cf377a4079718e8d709b3648d62a3a16ea39fbfbe759600c3d574caa15
{
"status": "VALID",
"response": {
"attestationType": "0x45564d5472616e73616374696f6e000000000000000000000000000000000000",
"sourceId": "0x7465737445544800000000000000000000000000000000000000000000000000",
"votingRound": "0",
"lowestUsedTimestamp": "1708999068",
"requestBody": {
"transactionHash": "0xd7eed8cf377a4079718e8d709b3648d62a3a16ea39fbfbe759600c3d574caa15",
"requiredConfirmations": "1",
"provideInput": true,
"listEvents": true,
"logIndices": []
},
"responseBody": {
"blockNumber": "5370899",
"timestamp": "1708999068",
"sourceAddress": "0x4C3dFaFc3207Eabb7dc8A6ab01Eb142C8655F373",
"isDeployment": false,
"receivingAddress": "0x6023e19d70C304eA16a3728ceDcb042791737EC3",
"value": "0",
"input": "0x40c10f190000000000000000000000004c3dfafc3207eabb7dc8a6ab01eb142c8655f37300000000000000000000000000000000000000000000000000000000000f4240",
"status": "1",
"events": [
{
"logIndex": "38",
"emitterAddress": "0x6023e19d70C304eA16a3728ceDcb042791737EC3",
"topics": [
"0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",
"0x0000000000000000000000000000000000000000000000000000000000000000",
"0x0000000000000000000000004c3dfafc3207eabb7dc8a6ab01eb142c8655f373"
],
"data": "0x00000000000000000000000000000000000000000000000000000000000f4240",
"removed": false
}
]
}
}
}
0x9dffa80b6daea45ed4bfc93bb72cdb893549fdefb81cb760b7ce08edef9859a6
{
"status": "VALID",
"response": {
"attestationType": "0x45564d5472616e73616374696f6e000000000000000000000000000000000000",
"sourceId": "0x7465737445544800000000000000000000000000000000000000000000000000",
"votingRound": "0",
"lowestUsedTimestamp": "1708999080",
"requestBody": {
"transactionHash": "0x9dffa80b6daea45ed4bfc93bb72cdb893549fdefb81cb760b7ce08edef9859a6",
"requiredConfirmations": "1",
"provideInput": true,
"listEvents": true,
"logIndices": []
},
"responseBody": {
"blockNumber": "5370900",
"timestamp": "1708999080",
"sourceAddress": "0x4C3dFaFc3207Eabb7dc8A6ab01Eb142C8655F373",
"isDeployment": false,
"receivingAddress": "0x6023e19d70C304eA16a3728ceDcb042791737EC3",
"value": "0",
"input": "0xa9059cbb000000000000000000000000ff02f742106b8a25c26e65c1f0d66bec3c90d42900000000000000000000000000000000000000000000000000000000000003e8",
"status": "1",
"events": [
{
"logIndex": "32",
"emitterAddress": "0x6023e19d70C304eA16a3728ceDcb042791737EC3",
"topics": [
"0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",
"0x0000000000000000000000004c3dfafc3207eabb7dc8a6ab01eb142c8655f373",
"0x000000000000000000000000ff02f742106b8a25c26e65c1f0d66bec3c90d429"
],
"data": "0x00000000000000000000000000000000000000000000000000000000000003e8",
"removed": false
}
]
}
}
}

Let's now decode the data you got back and explore the event in a little more detail.

{
"logIndex": "38",
"emitterAddress": "0x6023e19d70C304eA16a3728ceDcb042791737EC3",
"topics": [
"0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",
"0x0000000000000000000000000000000000000000000000000000000000000000",
"0x0000000000000000000000004c3dfafc3207eabb7dc8a6ab01eb142c8655f373"
],
"data": "0x00000000000000000000000000000000000000000000000000000000000f4240",
"removed": false
}
{
"logIndex": "32",
"emitterAddress": "0x6023e19d70C304eA16a3728ceDcb042791737EC3",
"topics": [
"0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",
"0x0000000000000000000000004c3dfafc3207eabb7dc8a6ab01eb142c8655f373",
"0x000000000000000000000000ff02f742106b8a25c26e65c1f0d66bec3c90d429"
],
"data": "0x00000000000000000000000000000000000000000000000000000000000003e8",
"removed": false
}

When processing the events, it is important to know which contract should be emitting the event (you don't want to count a memecoin transfer as a USDT transfer). The topics are the indexed arguments of the event, and the data is the non-indexed arguments. This was glossed over in the first part, but now it will be important.

If you take a look at the event definition:

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

You see that it has three arguments, two indexed and one non-indexed. However, there are three topics in the event. How do we interpret that? In our case, the first one is the event signature, and the other two are the indexed arguments. Importantly, that is not always the case (it is the case for events that are emitted by Solidity contracts, but not necessarily for other contracts or direct assembly code).

Let's now decode the event data. The second event has the following data:

"topics": [
"0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",
"0x0000000000000000000000004c3dfafc3207eabb7dc8a6ab01eb142c8655f373",
"0x000000000000000000000000ff02f742106b8a25c26e65c1f0d66bec3c90d429"
]

The first topic is the event signature, and the other two are the from and to addresses. You can easily see how they are zero-padded to accommodate the whole 32 bytes.

Similarly, the event in the first transaction that just minted 1,000,000 token wei (hex encoded in the data field) has the same zeroth topic, same recipient (topic with index 2), and zero address as the sender.

Let's upgrade the contract from before to tally ERC20 payments on external chains. You can do this by listening to events, decoding them, and using the decoded information.

Decoding top-level transaction data

You now know how to listen to events and decode them. Let's see how we can also decode top-level transaction data. Here, you will verify whether the top-level transaction really did increase the ERC20 allowance and see how to get top-level calldata.

The full code for this example is in the scripts/evm/tryERC20Allowance.ts and contracts/MintableERC20.sol files.

You initiate a simple allowance increase on Sepolia and then decode the calldata to see if it is really what you expect. The example response is something like this:

{
"status": "VALID",
"response": {
"attestationType": "0x45564d5472616e73616374696f6e000000000000000000000000000000000000",
"sourceId": "0x7465737445544800000000000000000000000000000000000000000000000000",
"votingRound": "0",
"lowestUsedTimestamp": "1709147568",
"requestBody": {
"transactionHash": "0x445ac68dd09198cb3b8202cb9ccba323d4d1c82157a076f97fd6682dfaa826d9",
"requiredConfirmations": "1",
"provideInput": true,
"listEvents": true,
"logIndices": []
},
"responseBody": {
"blockNumber": "5382600",
"timestamp": "1709147568",
"sourceAddress": "0x4C3dFaFc3207Eabb7dc8A6ab01Eb142C8655F373",
"isDeployment": false,
"receivingAddress": "0xc14FA393fa7248c73B74A303cf35D5e980E11e2C",
"value": "0",
"input": "0x095ea7b3000000000000000000000000ff02f742106b8a25c26e65c1f0d66bec3c90d42900000000000000000000000000000000000000000000000000000000000003e8",
"status": "1",
"events": [
{
"logIndex": "54",
"emitterAddress": "0xc14FA393fa7248c73B74A303cf35D5e980E11e2C",
"topics": [
"0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925",
"0x0000000000000000000000004c3dfafc3207eabb7dc8a6ab01eb142c8655f373",
"0x000000000000000000000000ff02f742106b8a25c26e65c1f0d66bec3c90d429"
],
"data": "0x00000000000000000000000000000000000000000000000000000000000003e8",
"removed": false
}
]
}
}
}
Result(2) [ '0xFf02F742106B8a25C26e65C1f0d66BEC3C90d429', 1000n ]

By now, you should be able to see that the emitted event was the Approval event, and the data is the new allowance (with the correct participant addresses in the topics).

What we want to take a look at is the input field. It contains the calldata of the top-level transaction. Since you know the signature of this method, you can easily decode it and get the result you expect.

Observing State Through Events

We do not have direct access to state on the other chain, but we can circumvent this using events. If we deploy a contract on the external chain that emits events pertaining to the state it can read (at that block) from the chain, we can easily observe this state (frozen at that point in time) on Flare. Let's see how we can easily observe the current status of ERC20 allowance.

The full code for this example is in the scripts/evm/tryStateChecking.ts and contracts/FallbackWithEventContract.sol files.

The contract is simple:

function getState(address target, bytes calldata cdata) external payable {
// Just forward the call to the contract we want to interact with
// Caution - this is very unsafe, as the calldata can be anything
// If this contract were to had some tokens for example, the calldata could be used to transfer them.
(bool result, bytes memory returnData) = target.call{value: msg.value}(cdata);
emit CallResult(target, result, msg.data, returnData);
// A bit safer way would be to only allow specific functions to be called or use something like this: https://github.com/gnosis/util-contracts/blob/main/contracts/storage/StorageAccessible.sol
}

Any call to this contract will be forwarded to the target contract, and the result will be emitted as an event.

The script is also relatively simple (though it does a lot of things).

We get the event in the same way as before, but now we also get the calldata and the target address. We need to do two things: First, decode the event to see what happened, and then decode the calldata to see what the state is. Then, decode both data bytes to see what we got. Importantly, it is necessary to know the structure of the event and the method we called to properly decode it.

The response is something like this:

Sepolia USDT deployed to: 0xf274cCf1f92F9B34FF5704802a9B690E1d3cbC38
FallbackWithEventContract deployed to: 0xfCcB55F281df58869593B64B48f8c2Fe66f91C5D
{
"status": "VALID",
"response": {
"attestationType": "0x45564d5472616e73616374696f6e000000000000000000000000000000000000",
"sourceId": "0x7465737445544800000000000000000000000000000000000000000000000000",
"votingRound": "0",
"lowestUsedTimestamp": "1709151372",
"requestBody": {
"transactionHash": "0xff86f77260f7623f24ea888dfd14c56380c5cece1a896bd2566d6b3596343e20",
"requiredConfirmations": "1",
"provideInput": true,
"listEvents": true,
"logIndices": []
},
"responseBody": {
"blockNumber": "5382901",
"timestamp": "1709151372",
"sourceAddress": "0x4C3dFaFc3207Eabb7dc8A6ab01Eb142C8655F373",
"isDeployment": false,
"receivingAddress": "0xfCcB55F281df58869593B64B48f8c2Fe66f91C5D",
"value": "0",
"input": "0xf29ca36c000000000000000000000000f274ccf1f92f9b34ff5704802a9b690e1d3cbc3800000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000044dd62ed3e0000000000000000000000004c3dfafc3207eabb7dc8a6ab01eb142c8655f373000000000000000000000000ff02f742106b8a25c26e65c1f0d66bec3c90d42900000000000000000000000000000000000000000000000000000000",
"status": "1",
"events": [
{
"logIndex": "4",
"emitterAddress": "0xfCcB55F281df58869593B64B48f8c2Fe66f91C5D",
"topics": [
"0xe1b725358090db1f537294b09c773c14622b44c1bc2832d105fb28cc48f5bd90"
],
"data": "0x000000000000000000000000f274ccf1f92f9b34ff5704802a9b690e1d3cbc380000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000044dd62ed3e0000000000000000000000004c3dfafc3207eabb7dc8a6ab01eb142c8655f373000000000000000000000000ff02f742106b8a25c26e65c1f0d66bec3c90d4290000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000bc614e",
"removed": false
}
]
}
}
}
Event data [
'0xf274cCf1f92F9B34FF5704802a9B690E1d3cbC38',
true,
'0xdd62ed3e0000000000000000000000004c3dfafc3207eabb7dc8a6ab01eb142c8655f373000000000000000000000000ff02f742106b8a25c26e65c1f0d66bec3c90d429',
'0x0000000000000000000000000000000000000000000000000000000000bc614e'
]
Method signature 0xdd62ed3e
Decoded calldata Result(2) [
'0x4C3dFaFc3207Eabb7dc8A6ab01Eb142C8655F373',
'0xFf02F742106B8a25C26e65C1f0d66BEC3C90d429'
]
Decoded state data Result(1) [ 12345678n ]

We can see that the event was emitted and all the calldata was properly decoded. Why is that important?

It means that you can now observe any state on the external blockchain without having to modify the contract on the external blockchain. This allows you to easily observe USDT movements, current token balances, and other state changes on the external blockchain.

State Observation and Decoding

The last example showed how you can observe the state on another blockchain and use it in TypeScript. Now, we will also see how to properly decode the event in a smart contract. We will use the same contract onchain as before to emit events, CallResult, and then decode them in the contract. The result will then be passed to the contract on Coston, which will first decode the full event, ensure that the correct function was called, and then decode the returned data (which is the state you want to observe).

The full contract that does this is in contracts/ERC20BalanceMonitor.sol and the accompanying script is in the scripts/evm/tryStateCheckingAndSave.ts file. What you want to do is simple: query the ERC20 balance of a specific address and save it in the contract storage. Here, you need to be careful, as this query is valid only at the time of the transaction; it might be different at the time of block creation and confirmation. Plus, keep in mind that emitting an event means executing a transaction, and that means gas, so you should be careful with how often you do this.

The process is the same as before: you invoke the contract, it emits the event, and you use the result to interact with the chain. But this time, you cheat a bit. Instead of waiting for the whole data connector process to finish, you use getResponse to get just the response without the proof. The ERC20BalanceMonitor then disregards the proof and just uses the response to process the data.

The number of events can be quite large and processing all of them can be tedious (and error-prone), so the easiest way is to find out which event is the one you want and add an index parameter to the function call.

The code for this:

/*
The function assumes that the event emitted in the eventIndex is the result of checking the balance of specific ERC20 token as emitted by FallbackWithEventContract (see previous guides).
The main idea is to first emit the event checking the balance and then properly decode it
*/
function confirmBalanceEvent(EVMTransaction.Proof calldata transaction, address tokenAddress, address targetAddress, uint256 eventIndex) public
{
// We explicitly ignore the proof here, but in production code, you should always verify the proof
// We ignore it so we can test the whole contract much faster on the same network using only the
// In this guide we will just use the `prepareResponse` endpoint which has everything we need but the proof
require(
true || isEVMTransactionProofValid(transaction),
"Invalid proof"
);

EVMTransaction.Event memory _event = transaction.data.responseBody.events[eventIndex];
// This just check the happy path - do kkep in mind, that this can possibly faked
// And keep in mind that the specification does not require the topic0 to be event signature
require(
_event.topics[0] == keccak256("CallResult(address,bool,bytes,bytes)"),
"Invalid event"
);

// _event.emitterAddress should be the contract we "trust" to correctly call the ERC20 token

(address target, bool result, bytes memory callData, bytes memory returnData) = abi.decode(
_event.data,
(address, bool, bytes, bytes)
);

require(target == tokenAddress, "Invalid token address");


bytes memory expectedCalldata = abi.encodeWithSignature("balanceOf(address)", targetAddress);
require(
keccak256(callData) == keccak256(expectedCalldata),
"Invalid calldata"
);
// If a tuple was returned from the call, we can unpack it using abi.decode in the same way as in the event data decoding
uint256 balance = abi.decode(returnData, (uint256));

balances[transaction.data.responseBody.blockNumber] = BalanceInfo({
holder: targetAddress,
token: tokenAddress,
amount: balance,
blockNumber: transaction.data.responseBody.blockNumber,
timestamp: transaction.data.responseBody.timestamp,
rawEvent: _event,
proofHash: keccak256(abi.encode(transaction))
});
}

We just ignore the proof, but then the fun part starts. We get the top-level event out of the response (this is the one that contains calldata and return data), check that the topic matches, and then decode the resulting data. Be careful, decoding the data might fail if you don't have the correct signature, so the example code is fine to show, but you might want to add more checks in production code.

Once the data of the top-level event is decoded, we check if the call data is what we expect and then decode the return data to get the balance, which is again dependent on what kind of return value was produced in the transaction. Again, the return data needs to be decoded (it might return something more complicated than just one uint256), but it is easy to get the full result. Once you have all this, you just write it to the contract storage, and you are done.

Let's take a look at the test code and show a simple trick that is also hidden in there.

The code is practically the same as before: you create a transaction, query the data connector, and use the data in the contract. But this time, everything is done on the same (Coston - testSGB) network. This makes it a bit easier to test, as you don't need to change the network, but it is a minor thing.

It does sound strange (and pointless) to allow the Data Connector to be used on the same network, but the main improvement comes from the top-level relayer coming in the FSP. Once the Data Connector is included in the top-level protocol, any Data Connector data is immediately relayed to externally connected chains via relay (as is the FTSO data). This means that external chains can also observe what is happening on Flare.

Think about this: up until now, you only relayed information from other chains to Flare, but now any example from the EVM part can immediately be replicated on the Sepolia chain with Flare being the source chain (where things happen).