EVM Connectivity

March. 15. 2024

INTERMEDIATE
STATE CONNECTOR
EVM
FLARE

So far, the State Connector has allowed Flare to connect to a few other chains and gather different data. In the previous blogposts, we have learned, how the State Connector works and what kind of different attestations we can get from it. Attestations that we know so far are:

  • Simple payment
  • Non-existence of a payment with reference
  • Balance decreasing transaction
  • Block height confirmation
  • Address validity check

and we know, that the State Connector allows Flare to connect to the following chains:

  • BTC
  • DOGE
  • XRP Ledger

In this blogpost, we will uncover a new type of attestation and a new chain that Flare will be able to connect to, how to attest to such transactions and, most importantly, what kind of data we can get from them. We are moving from the world of UTXO chains to the world of EVM chains with a new EVMTransaction attestation type and two different chains: Ethereum and Flare (or testnets Sepolia and Coston2 for the Coston testnet). This approach gives us a whole new set of possibilities: Firstly, the chain is now account-based (as was XRP Ledger), and secondly, transactions on EVM chains can be much more complex as we enter the world of smart contracts.

The information that the State Connector provides is similar to what was provided before (sender and recipient, amount, block, timestamp, etc.), but since we are on a smart chain now, we can also get additional things, namely, we can extract the full data about events that were emitted during the transaction, and we 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 toplevel Request in the EVMTransaction has the same structure as others:

1// SPDX-License-Identifier: MIT
2pragma solidity >=0.7.6 <0.9;
3
4/**
5 * @custom:name EVMTransaction
6 * @custom:id 0x06
7 * @custom:supported ETH, FLR, SGB, testETH, testFLR, testSGB
8 * @author Flare
9 * @notice A relay of a transaction from an EVM chain.
10 * This type is only relevant for EVM-compatible chains.
11 * @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.
12 * If an indicated event does not exist, the request is rejected.
13 * @custom:lut `timestamp`
14 */
15interface EVMTransaction {
16 /**
17 * @notice Toplevel request
18 * @param attestationType ID of the attestation type.
19 * @param sourceId ID of the data source.
20 * @param messageIntegrityCode `MessageIntegrityCode` that is derived from the expected response.
21 * @param requestBody Data defining the request. Type (struct) and interpretation is determined by the `attestationType`.
22 */
23 struct Request {
24 bytes32 attestationType;
25 bytes32 sourceId;
26 bytes32 messageIntegrityCode;
27 RequestBody requestBody;
28 }
29
30 /**
31 * @notice Toplevel response
32 * @param attestationType Extracted from the request.
33 * @param sourceId Extracted from the request.
34 * @param votingRound The ID of the State Connector round in which the request was considered.
35 * @param lowestUsedTimestamp The lowest timestamp used to generate the response.
36 * @param requestBody Extracted from the request.
37 * @param responseBody Data defining the response. The verification rules for the construction of the response body and the type are defined per specific `attestationType`.
38 */
39 struct Response {
40 bytes32 attestationType;
41 bytes32 sourceId;
42 uint64 votingRound;
43 uint64 lowestUsedTimestamp;
44 RequestBody requestBody;
45 ResponseBody responseBody;
46 }
47
48 /**
49 * @notice Toplevel proof
50 * @param merkleProof Merkle proof corresponding to the attestation response.
51 * @param data Attestation response.
52 */
53 struct Proof {
54 bytes32[] merkleProof;
55 Response data;
56 }
57
58 /**
59 * @notice Request body for EVM transaction attestation type
60 * @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`
61 * with respect to the set specifications. If possible, the contact should only require one `logIndex`.
62 * @param transactionHash Hash of the transaction(transactionHash).
63 * @param requiredConfirmations The height at which a block is considered confirmed by the requestor.
64 * @param provideInput If true, "input" field is included in the response.
65 * @param listEvents If true, events indicated by `logIndices` are included in the response. Otherwise, no events are included in the response.
66 * @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.
67 */
68 struct RequestBody {
69 bytes32 transactionHash;
70 uint16 requiredConfirmations;
71 bool provideInput;
72 bool listEvents;
73 uint32[] logIndices;
74 }
75
76 /**
77 * @notice Response body for EVM transaction attestation type
78 * @custom:below The fields are in line with [transaction](https://ethereum.org/en/developers/docs/apis/json-rpc/#eth_gettransactionbyhash) provided by EVM node.
79 * @param blockNumber Number of the block in which the transaction is included.
80 * @param timestamp Timestamp of the block in which the transaction is included.
81 * @param sourceAddress The address (from) that signed the transaction.
82 * @param isDeployment Indicate whether it is a contract creation transaction.
83 * @param receivingAddress The address (to) of the receiver of the initial transaction. Zero address if `isDeployment` is `true`.
84 * @param value The value transferred by the initial transaction in wei.
85 * @param input If `provideInput`, this is the data send along with the initial transaction. Otherwise it is the default value `0x00`.
86 * @param status Status of the transaction 1 - success, 0 - failure.
87 * @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.
88 */
89 struct ResponseBody {
90 uint64 blockNumber;
91 uint64 timestamp;
92 address sourceAddress;
93 bool isDeployment;
94 address receivingAddress;
95 uint256 value;
96 bytes input;
97 uint8 status;
98 Event[] events;
99 }
100
101 /**
102 * @notice Event log record
103 * @custom:above An `Event` is a struct with the following fields:
104 * @custom:below The fields are in line with [EVM event logs](https://ethereum.org/en/developers/docs/apis/json-rpc/#eth_getfilterchanges).
105 * @param logIndex The consecutive number of the event in block.
106 * @param emitterAddress The address of the contract that emitted the event.
107 * @param topics An array of up to four 32-byte strings of indexed log arguments.
108 * @param data Concatenated 32-byte strings of non-indexed log arguments. At least 32 bytes long.
109 * @param removed It is `true` if the log was removed due to a chain reorganization and `false` if it is a valid log.
110 */
111 struct Event {
112 uint32 logIndex;
113 address emitterAddress;
114 bytes32[] topics;
115 bytes data;
116 bool removed;
117 }
118}

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

Request Body

The updated RequestBody is defined as follows:

1 /**
2 * @notice Request body for EVM transaction attestation type
3 * @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`
4 * with respect to the set specifications. If possible, the contact should only require one `logIndex`.
5 * @param transactionHash Hash of the transaction(transactionHash).
6 * @param requiredConfirmations The height at which a block is considered confirmed by the requestor.
7 * @param provideInput If true, "input" field is included in the response.
8 * @param listEvents If true, events indicated by `logIndices` are included in the response. Otherwise, no events are included in the response.
9 * @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.
10 */
11 struct RequestBody {
12 bytes32 transactionHash;
13 uint16 requiredConfirmations;
14 bool provideInput;
15 bool listEvents;
16 uint32[] logIndices;
17 }
  • TransactionHash: Hash of the transaction we are observing.
  • RequiredConfirmations: The number of blocks after the transaction that we are requesting that must be visible to the attestation client to consider this transaction as finalized. In contrast with the previous payment (or block height) attestation, where the amount of block confirmations was set per chain, this type is more liberal and allows us to choose how many confirmations we want and thus adapt our security assumptions (about the other chain).
  • provideInput: If the response should also contain the input of the transaction. We can always include the input, but this might produce a large data struct that we will need to supply when using this transaction. If you don't need to use this data, it is advisable to not include it, as you incur additional gas cost both for supplying it to the verification contract and making a transaction. Keep in mind, that it might be useful, for example, to check what contract was deployed or what was the toplevel method that was executed.
  • listEvents: Events are an important and very powerful tool when interacting with EVM chains, but including them adds additional costs (the same as with input), so 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. In any case, 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 (in any case, what kind of transaction did you make to require 50 events?).

Response Body

Let's see what a treasure trove of information an EVM attestation type is:

1 /**
2 * @notice Response body for EVM transaction attestation type
3 * @custom:below The fields are in line with [transaction](https://ethereum.org/en/developers/docs/apis/json-rpc/#eth_gettransactionbyhash) provided by EVM node.
4 * @param blockNumber Number of the block in which the transaction is included.
5 * @param timestamp Timestamp of the block in which the transaction is included.
6 * @param sourceAddress The address (from) that signed the transaction.
7 * @param isDeployment Indicate whether it is a contract creation transaction.
8 * @param receivingAddress The address (to) of the receiver of the initial transaction. Zero address if `isDeployment` is `true`.
9 * @param value The value transferred by the initial transaction in wei.
10 * @param input If `provideInput`, this is the data send along with the initial transaction. Otherwise it is the default value `0x00`.
11 * @param status Status of the transaction 1 - success, 0 - failure.
12 * @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.
13 */
14 struct ResponseBody {
15 uint64 blockNumber;
16 uint64 timestamp;
17 address sourceAddress;
18 bool isDeployment;
19 address receivingAddress;
20 uint256 value;
21 bytes input;
22 uint8 status;
23 Event[] events;
24 }
25
26 /**
27 * @notice Event log record
28 * @custom:above An `Event` is a struct with the following fields:
29 * @custom:below The fields are in line with [EVM event logs](https://ethereum.org/en/developers/docs/apis/json-rpc/#eth_getfilterchanges).
30 * @param logIndex The consecutive number of the event in block.
31 * @param emitterAddress The address of the contract that emitted the event.
32 * @param topics An array of up to four 32-byte strings of indexed log arguments.
33 * @param data Concatenated 32-byte strings of non-indexed log arguments. At least 32 bytes long.
34 * @param removed It is `true` if the log was removed due to a chain reorganization and `false` if it is a valid log.
35 */
36 struct Event {
37 uint32 logIndex;
38 address emitterAddress;
39 bytes32[] topics;
40 bytes data;
41 bool removed;
42 }

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 we don't have to operate with strings or address hashes.
  • isDeployment: Flag indicating if this transaction was contract deployment.
  • receivingAddress: The to address of the transaction (this is a zero address if we are dealing with contract deployment). Keep in mind, that 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 things 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, that 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.

Usage examples

Now we know how to request an attestation and what we are getting in return, let's play around and see some examples. Examples are now a bit more involved and each of the examples will come in a few parts:

  • Script making a dummy transaction on 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 us to explain exactly how long the waiting for each phase takes (something we have not focused on until now).

Simple transaction with a value

Let's start small. We will create a smart contract that just tallies the toplevel amounts transferred to a designated address on sepolia.

The scenario is pretty simple. We have a "payment" to Externally Owned Account (EOA - so not smart contract) on sepolia, and anyone can send funds there and prove this. On the Flare side, we will deploy a contract, which 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.

We won't be copy-pasting the full code here, but we 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.

We first run yarn hardhat run scripts/evm/trySimpleTransaction --network sepolia to deploy simple FallbackContract on Sepolia. This contract will just emit an event when the fallback function is called. We 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 we can see what we will get in the next part).

Here is the example results:

10xac640ab047aa1097ddd473e5940921eb500a9912b33072b8532617692428830e
2{
3 "status": "VALID",
4 "response": {
5 "attestationType": "0x45564d5472616e73616374696f6e000000000000000000000000000000000000",
6 "sourceId": "0x7465737445544800000000000000000000000000000000000000000000000000",
7 "votingRound": "0",
8 "lowestUsedTimestamp": "1708907688",
9 "requestBody": {
10 "transactionHash": "0xac640ab047aa1097ddd473e5940921eb500a9912b33072b8532617692428830e",
11 "requiredConfirmations": "1",
12 "provideInput": true,
13 "listEvents": true,
14 "logIndices": []
15 },
16 "responseBody": {
17 "blockNumber": "5363670",
18 "timestamp": "1708907688",
19 "sourceAddress": "0x4C3dFaFc3207Eabb7dc8A6ab01Eb142C8655F373",
20 "isDeployment": false,
21 "receivingAddress": "0xFf02F742106B8a25C26e65C1f0d66BEC3C90d429",
22 "value": "10",
23 "input": "0x0123456789",
24 "status": "1",
25 "events": []
26 }
27 }
28}
290x7eb54cde238fc700be31c98af7e4df8c4fc96fd5c634c490183ca612a481efcc
30{
31 "status": "VALID",
32 "response": {
33 "attestationType": "0x45564d5472616e73616374696f6e000000000000000000000000000000000000",
34 "sourceId": "0x7465737445544800000000000000000000000000000000000000000000000000",
35 "votingRound": "0",
36 "lowestUsedTimestamp": "1708907712",
37 "requestBody": {
38 "transactionHash": "0x7eb54cde238fc700be31c98af7e4df8c4fc96fd5c634c490183ca612a481efcc",
39 "requiredConfirmations": "1",
40 "provideInput": true,
41 "listEvents": true,
42 "logIndices": []
43 },
44 "responseBody": {
45 "blockNumber": "5363672",
46 "timestamp": "1708907712",
47 "sourceAddress": "0x4C3dFaFc3207Eabb7dc8A6ab01Eb142C8655F373",
48 "isDeployment": false,
49 "receivingAddress": "0xeBBf567beDe2D8842dF538Cf64E0bE9976183853",
50 "value": "10",
51 "input": "0x9876543210",
52 "status": "1",
53 "events": [
54 {
55 "logIndex": "160",
56 "emitterAddress": "0xeBBf567beDe2D8842dF538Cf64E0bE9976183853",
57 "topics": [
58 "0xaca09dd456ca888dccf8cc966e382e6e3042bb7e4d2d7815015f844edeafce42"
59 ],
60 "data": "0x0000000000000000000000004c3dfafc3207eabb7dc8a6ab01eb142c8655f373000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000059876543210000000000000000000000000000000000000000000000000000000",
61 "removed": false
62 }
63 ]
64 }
65 }
66}

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

Here, the State Connector part comes into play. You have already seen it in the previous blogposts, so we will just quickly scan through it. The code is a bit more involved, as we are now working with multiple transactions (this is not EVMTransaction specific, but it is a good example of how you can use the State Connector to do more complex things). Again, we get an encoded attestation request (one for each transaction) and then we submit them to the State Connector. Once this is done, we wait for the round to be confirmed (see the while loop that takes most of the time) and then we 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, we 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: we can use the information from a transaction to do whatever we want. We won't just write it to the list of all transactions and be done. But we will try to decode the event data and see what we can get from it. As said before, the event data is specific to the event and we need to know the event structure to decode it properly. In this case, we know how it looks and the decoding is done by the built-in abi.decode. We then just push the decoded data in struct form to the list of events and we are done. A word of caution, the 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 we have both proofs and the contract deployed, we just call the collectPayment method with the proofs, and we are done (unless something goes wrong, then we will have to wait for the next round and try again).

The result is something like this:

1Rounds: [ '809307', '809307' ]
2Waiting for the round to be confirmed 809303n 809307
3Waiting for the round to be confirmed 809303n 809307
4Waiting for the round to be confirmed 809303n 809307
5Waiting for the round to be confirmed 809304n 809307
6Waiting for the round to be confirmed 809304n 809307
7Waiting for the round to be confirmed 809304n 809307
8Waiting for the round to be confirmed 809304n 809307
9Waiting for the round to be confirmed 809304n 809307
10Waiting for the round to be confirmed 809305n 809307
11Waiting for the round to be confirmed 809305n 809307
12Waiting for the round to be confirmed 809305n 809307
13Waiting for the round to be confirmed 809305n 809307
14Waiting for the round to be confirmed 809306n 809307
15Waiting for the round to be confirmed 809306n 809307
16Waiting for the round to be confirmed 809306n 809307
17Waiting for the round to be confirmed 809306n 809307
18Waiting for the round to be confirmed 809306n 809307
19Round confirmed, getting proof
20Successfully submitted source code for contract
21contracts/EthereumPaymentCollector.sol:EthereumPaymentCollector at 0x7cf6E7aeFD0207a5bE9a7DbcDA560fc7a6dBD7B4
22for verification on the block explorer. Waiting for verification result...
23
24Successfully verified contract EthereumPaymentCollector on the block explorer.
25https://coston-explorer.flare.network/address/0x7cf6E7aeFD0207a5bE9a7DbcDA560fc7a6dBD7B4#code
26
27{
28 "data": {
29 "attestationType": "0x45564d5472616e73616374696f6e000000000000000000000000000000000000",
30 "lowestUsedTimestamp": "1708907688",
31 "requestBody": {
32 "listEvents": true,
33 "logIndices": [],
34 "provideInput": true,
35 "requiredConfirmations": "1",
36 "transactionHash": "0xac640ab047aa1097ddd473e5940921eb500a9912b33072b8532617692428830e"
37 },
38 "responseBody": {
39 "blockNumber": "5363670",
40 "events": [],
41 "input": "0x0123456789",
42 "isDeployment": false,
43 "receivingAddress": "0xFf02F742106B8a25C26e65C1f0d66BEC3C90d429",
44 "sourceAddress": "0x4C3dFaFc3207Eabb7dc8A6ab01Eb142C8655F373",
45 "status": "1",
46 "timestamp": "1708907688",
47 "value": "10"
48 },
49 "sourceId": "0x7465737445544800000000000000000000000000000000000000000000000000",
50 "votingRound": "809307"
51 },
52 "merkleProof": [
53 "0x56faf895bbcb0b2a6f3bc283ea5e1793b224dca8b4b99240a34cee6d9bf1b8f3",
54 "0x13ef0de709e7b0485f7623f5a0ad5b56aa23626fbffe5e7f4502bb7be5e0bf7e",
55 "0xf72c31824174676516a9c5d9713cb1ae8866cac71462fe2b1a3c1e1b9418a94f"
56 ]
57}
58{
59 "data": {
60 "attestationType": "0x45564d5472616e73616374696f6e000000000000000000000000000000000000",
61 "lowestUsedTimestamp": "1708907712",
62 "requestBody": {
63 "listEvents": true,
64 "logIndices": [],
65 "provideInput": true,
66 "requiredConfirmations": "1",
67 "transactionHash": "0x7eb54cde238fc700be31c98af7e4df8c4fc96fd5c634c490183ca612a481efcc"
68 },
69 "responseBody": {
70 "blockNumber": "5363672",
71 "events": [
72 {
73 "data": "0x0000000000000000000000004c3dfafc3207eabb7dc8a6ab01eb142c8655f373000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000059876543210000000000000000000000000000000000000000000000000000000",
74 "emitterAddress": "0xeBBf567beDe2D8842dF538Cf64E0bE9976183853",
75 "logIndex": "160",
76 "removed": false,
77 "topics": [
78 "0xaca09dd456ca888dccf8cc966e382e6e3042bb7e4d2d7815015f844edeafce42"
79 ]
80 }
81 ],
82 "input": "0x9876543210",
83 "isDeployment": false,
84 "receivingAddress": "0xeBBf567beDe2D8842dF538Cf64E0bE9976183853",
85 "sourceAddress": "0x4C3dFaFc3207Eabb7dc8A6ab01Eb142C8655F373",
86 "status": "1",
87 "timestamp": "1708907712",
88 "value": "10"
89 },
90 "sourceId": "0x7465737445544800000000000000000000000000000000000000000000000000",
91 "votingRound": "809307"
92 },
93 "merkleProof": [
94 "0x8e45d2d564bf7d652cf904a72e53f5e7e34d7e5e184906afda92f755e99cd421",
95 "0x13ef0de709e7b0485f7623f5a0ad5b56aa23626fbffe5e7f4502bb7be5e0bf7e",
96 "0xf72c31824174676516a9c5d9713cb1ae8866cac71462fe2b1a3c1e1b9418a94f"
97 ]
98}

An important thing to keep in mind is the following: On the previous attestation types, we were only able to get transactions in the last two days (this is attestation type specific).

Event emittance + decoding

As already said, 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, we will deploy an ERC20 contract on Sepolia, mint some tokens and send them to an address. The full code is available in scripts/evm/tryERC20transfers.ts and contracts/MintableERC20.sol files.

A sample response for the ERC20 transaction would look like this:

1Sepolia USDT deployed to: 0x6023e19d70C304eA16a3728ceDcb042791737EC3
20xd7eed8cf377a4079718e8d709b3648d62a3a16ea39fbfbe759600c3d574caa15
3{
4 "status": "VALID",
5 "response": {
6 "attestationType": "0x45564d5472616e73616374696f6e000000000000000000000000000000000000",
7 "sourceId": "0x7465737445544800000000000000000000000000000000000000000000000000",
8 "votingRound": "0",
9 "lowestUsedTimestamp": "1708999068",
10 "requestBody": {
11 "transactionHash": "0xd7eed8cf377a4079718e8d709b3648d62a3a16ea39fbfbe759600c3d574caa15",
12 "requiredConfirmations": "1",
13 "provideInput": true,
14 "listEvents": true,
15 "logIndices": []
16 },
17 "responseBody": {
18 "blockNumber": "5370899",
19 "timestamp": "1708999068",
20 "sourceAddress": "0x4C3dFaFc3207Eabb7dc8A6ab01Eb142C8655F373",
21 "isDeployment": false,
22 "receivingAddress": "0x6023e19d70C304eA16a3728ceDcb042791737EC3",
23 "value": "0",
24 "input": "0x40c10f190000000000000000000000004c3dfafc3207eabb7dc8a6ab01eb142c8655f37300000000000000000000000000000000000000000000000000000000000f4240",
25 "status": "1",
26 "events": [
27 {
28 "logIndex": "38",
29 "emitterAddress": "0x6023e19d70C304eA16a3728ceDcb042791737EC3",
30 "topics": [
31 "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",
32 "0x0000000000000000000000000000000000000000000000000000000000000000",
33 "0x0000000000000000000000004c3dfafc3207eabb7dc8a6ab01eb142c8655f373"
34 ],
35 "data": "0x00000000000000000000000000000000000000000000000000000000000f4240",
36 "removed": false
37 }
38 ]
39 }
40 }
41}
420x9dffa80b6daea45ed4bfc93bb72cdb893549fdefb81cb760b7ce08edef9859a6
43{
44 "status": "VALID",
45 "response": {
46 "attestationType": "0x45564d5472616e73616374696f6e000000000000000000000000000000000000",
47 "sourceId": "0x7465737445544800000000000000000000000000000000000000000000000000",
48 "votingRound": "0",
49 "lowestUsedTimestamp": "1708999080",
50 "requestBody": {
51 "transactionHash": "0x9dffa80b6daea45ed4bfc93bb72cdb893549fdefb81cb760b7ce08edef9859a6",
52 "requiredConfirmations": "1",
53 "provideInput": true,
54 "listEvents": true,
55 "logIndices": []
56 },
57 "responseBody": {
58 "blockNumber": "5370900",
59 "timestamp": "1708999080",
60 "sourceAddress": "0x4C3dFaFc3207Eabb7dc8A6ab01Eb142C8655F373",
61 "isDeployment": false,
62 "receivingAddress": "0x6023e19d70C304eA16a3728ceDcb042791737EC3",
63 "value": "0",
64 "input": "0xa9059cbb000000000000000000000000ff02f742106b8a25c26e65c1f0d66bec3c90d42900000000000000000000000000000000000000000000000000000000000003e8",
65 "status": "1",
66 "events": [
67 {
68 "logIndex": "32",
69 "emitterAddress": "0x6023e19d70C304eA16a3728ceDcb042791737EC3",
70 "topics": [
71 "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",
72 "0x0000000000000000000000004c3dfafc3207eabb7dc8a6ab01eb142c8655f373",
73 "0x000000000000000000000000ff02f742106b8a25c26e65c1f0d66bec3c90d429"
74 ],
75 "data": "0x00000000000000000000000000000000000000000000000000000000000003e8",
76 "removed": false
77 }
78 ]
79 }
80 }
81}

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

1{
2 "logIndex": "38",
3 "emitterAddress": "0x6023e19d70C304eA16a3728ceDcb042791737EC3",
4 "topics": [
5 "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",
6 "0x0000000000000000000000000000000000000000000000000000000000000000",
7 "0x0000000000000000000000004c3dfafc3207eabb7dc8a6ab01eb142c8655f373"
8 ],
9 "data": "0x00000000000000000000000000000000000000000000000000000000000f4240",
10 "removed": false
11}
12{
13 "logIndex": "32",
14 "emitterAddress": "0x6023e19d70C304eA16a3728ceDcb042791737EC3",
15 "topics": [
16 "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",
17 "0x0000000000000000000000004c3dfafc3207eabb7dc8a6ab01eb142c8655f373",
18 "0x000000000000000000000000ff02f742106b8a25c26e65c1f0d66bec3c90d429"
19 ],
20 "data": "0x00000000000000000000000000000000000000000000000000000000000003e8",
21 "removed": false
22}

Each transaction has emitted a single event, and we can see that the emitterAddress is the address of the USDT contract, the one we want to observe. 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. We glossed over this in the first part, but now this will be important. If we take a look at the event definition

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

We see, that it has three arguments, two indexed and one non-indexed. But there are three topics in the event. How do we interpret that? Well 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

1"topics": [
2 "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",
3 "0x0000000000000000000000004c3dfafc3207eabb7dc8a6ab01eb142c8655f373",
4 "0x000000000000000000000000ff02f742106b8a25c26e65c1f0d66bec3c90d429"
5 ],

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 1000000 token wei (hex encoded in the data field) has the same zeroth topic, same recipient (topic with index 2), and zero address as sender.

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

Custom event

Here, we will create a simple contract on Sepolia and follow the events it emits, just to see another example of how events function. -->

Toplevel transaction + data decoding (allowance of erc20)

We now know how to listen to events (and decode them). Let's see how we can also decode toplevel transaction data. Here, we will verify whether the toplevel transaction really did increase the ERC20 allowance and see how to get toplevel calldata.

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

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

1{
2 "status": "VALID",
3 "response": {
4 "attestationType": "0x45564d5472616e73616374696f6e000000000000000000000000000000000000",
5 "sourceId": "0x7465737445544800000000000000000000000000000000000000000000000000",
6 "votingRound": "0",
7 "lowestUsedTimestamp": "1709147568",
8 "requestBody": {
9 "transactionHash": "0x445ac68dd09198cb3b8202cb9ccba323d4d1c82157a076f97fd6682dfaa826d9",
10 "requiredConfirmations": "1",
11 "provideInput": true,
12 "listEvents": true,
13 "logIndices": []
14 },
15 "responseBody": {
16 "blockNumber": "5382600",
17 "timestamp": "1709147568",
18 "sourceAddress": "0x4C3dFaFc3207Eabb7dc8A6ab01Eb142C8655F373",
19 "isDeployment": false,
20 "receivingAddress": "0xc14FA393fa7248c73B74A303cf35D5e980E11e2C",
21 "value": "0",
22 "input": "0x095ea7b3000000000000000000000000ff02f742106b8a25c26e65c1f0d66bec3c90d42900000000000000000000000000000000000000000000000000000000000003e8",
23 "status": "1",
24 "events": [
25 {
26 "logIndex": "54",
27 "emitterAddress": "0xc14FA393fa7248c73B74A303cf35D5e980E11e2C",
28 "topics": [
29 "0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925",
30 "0x0000000000000000000000004c3dfafc3207eabb7dc8a6ab01eb142c8655f373",
31 "0x000000000000000000000000ff02f742106b8a25c26e65c1f0d66bec3c90d429"
32 ],
33 "data": "0x00000000000000000000000000000000000000000000000000000000000003e8",
34 "removed": false
35 }
36 ]
37 }
38 }
39}
40Result(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 is the input field. It contains the calldata of the toplevel transaction. Since we know the signature of this method, we can easily decode it and get the result we expect.

State observation 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 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 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

1function getState(address target, bytes calldata cdata) external payable {
2 // Just forward the call to the contract we want to interact with
3 // Caution - this is very unsafe, as the calldata can be anything
4 // If this contract were to had some tokens for example, the calldata could be used to transfer them.
5 (bool result, bytes memory returnData) = target.call{value: msg.value}(cdata);
6 emit CallResult(target, result, msg.data, returnData);
7 // 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
8 }

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

And 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, we decode the event to see what happened and then we decode the calldata to see what the state is. And then we 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:

1Sepolia USDT deployed to: 0xf274cCf1f92F9B34FF5704802a9B690E1d3cbC38
2FallbackWithEventContract deployed to: 0xfCcB55F281df58869593B64B48f8c2Fe66f91C5D
3{
4 "status": "VALID",
5 "response": {
6 "attestationType": "0x45564d5472616e73616374696f6e000000000000000000000000000000000000",
7 "sourceId": "0x7465737445544800000000000000000000000000000000000000000000000000",
8 "votingRound": "0",
9 "lowestUsedTimestamp": "1709151372",
10 "requestBody": {
11 "transactionHash": "0xff86f77260f7623f24ea888dfd14c56380c5cece1a896bd2566d6b3596343e20",
12 "requiredConfirmations": "1",
13 "provideInput": true,
14 "listEvents": true,
15 "logIndices": []
16 },
17 "responseBody": {
18 "blockNumber": "5382901",
19 "timestamp": "1709151372",
20 "sourceAddress": "0x4C3dFaFc3207Eabb7dc8A6ab01Eb142C8655F373",
21 "isDeployment": false,
22 "receivingAddress": "0xfCcB55F281df58869593B64B48f8c2Fe66f91C5D",
23 "value": "0",
24 "input": "0xf29ca36c000000000000000000000000f274ccf1f92f9b34ff5704802a9b690e1d3cbc3800000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000044dd62ed3e0000000000000000000000004c3dfafc3207eabb7dc8a6ab01eb142c8655f373000000000000000000000000ff02f742106b8a25c26e65c1f0d66bec3c90d42900000000000000000000000000000000000000000000000000000000",
25 "status": "1",
26 "events": [
27 {
28 "logIndex": "4",
29 "emitterAddress": "0xfCcB55F281df58869593B64B48f8c2Fe66f91C5D",
30 "topics": [
31 "0xe1b725358090db1f537294b09c773c14622b44c1bc2832d105fb28cc48f5bd90"
32 ],
33 "data": "0x000000000000000000000000f274ccf1f92f9b34ff5704802a9b690e1d3cbc380000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000044dd62ed3e0000000000000000000000004c3dfafc3207eabb7dc8a6ab01eb142c8655f373000000000000000000000000ff02f742106b8a25c26e65c1f0d66bec3c90d4290000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000bc614e",
34 "removed": false
35 }
36 ]
37 }
38 }
39}
40Event data [
41 '0xf274cCf1f92F9B34FF5704802a9B690E1d3cbC38',
42 true,
43 '0xdd62ed3e0000000000000000000000004c3dfafc3207eabb7dc8a6ab01eb142c8655f373000000000000000000000000ff02f742106b8a25c26e65c1f0d66bec3c90d429',
44 '0x0000000000000000000000000000000000000000000000000000000000bc614e'
45]
46Method signature 0xdd62ed3e
47Decoded calldata Result(2) [
48 '0x4C3dFaFc3207Eabb7dc8A6ab01Eb142C8655F373',
49 '0xFf02F742106B8a25C26e65C1f0d66BEC3C90d429'
50]
51Decoded state data Result(1) [ 12345678n ]

We can see that the event was emitted and all the calldata was properly decoded. Why is that important? Well it means, that we can now observe any state on the external blockchain without having to modify the contract on the external blockchain. This means, that we can easily observe USDT movements, current token balances...

State observation and decoding

The last example showed how we 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 on-chain as before to emit events, CallResult, and then decode them in the contract. The result will then be passed to the contract on Coston that will first decode the full event, make sure that we called the correct function, and then decode the returned data (which is the state we want to observe).

The full contract that does this in the contracts/ERC20BalanceMonitor.sol and the accompanying script is in the scripts/evm/tryStateCheckingAndSave.ts file. What we 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 the 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, we invoke the contract, it emits the event, and we use the result to interact with the chain. But this time we cheat a bit. Instead of waiting for the whole state connector process to finish, we 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. Let's see the code

1/*
2The 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 blogpost).
3The main idea is to first emit the event checking the balance and then properly decode it
4*/
5function confirmBalanceEvent(EVMTransaction.Proof calldata transaction, address tokenAddress, address targetAddress, uint256 eventIndex) public
6{
7 // We explicitly ignore the proof here, but in production code, you should always verify the proof
8 // We ignore it so we can test the whole contract much faster on the same network using only the
9 // In this blogpost we will just use the `prepareResponse` endpoint which has everything we need but the proof
10 require(
11 true || isEVMTransactionProofValid(transaction),
12 "Invalid proof"
13 );
14
15 EVMTransaction.Event memory _event = transaction.data.responseBody.events[eventIndex];
16 // This just check the happy path - do kkep in mind, that this can possibly faked
17 // And keep in mind that the specification does not require the topic0 to be event signature
18 require(
19 _event.topics[0] == keccak256("CallResult(address,bool,bytes,bytes)"),
20 "Invalid event"
21 );
22
23 // _event.emitterAddress should be the contract we "trust" to correctly call the ERC20 token
24
25 (address target, bool result, bytes memory callData, bytes memory returnData) = abi.decode(
26 _event.data,
27 (address, bool, bytes, bytes)
28 );
29
30 require(target == tokenAddress, "Invalid token address");
31
32
33 bytes memory expectedCalldata = abi.encodeWithSignature("balanceOf(address)", targetAddress);
34 require(
35 keccak256(callData) == keccak256(expectedCalldata),
36 "Invalid calldata"
37 );
38 // 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
39 uint256 balance = abi.decode(returnData, (uint256));
40
41 balances[transaction.data.responseBody.blockNumber] = BalanceInfo({
42 holder: targetAddress,
43 token: tokenAddress,
44 amount: balance,
45 blockNumber: transaction.data.responseBody.blockNumber,
46 timestamp: transaction.data.responseBody.timestamp,
47 rawEvent: _event,
48 proofHash: keccak256(abi.encode(transaction))
49 });
50}

We just ignore the proof - as said before, but then the fun part starts. We get the toplevel 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 we 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 data of the toplevel 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 we produced in the transaction. Again, the return data needs to be decoded (t might return something more complicated than just one uint256), but it is easy to get the full result. Once we have all this, we just write it to the contract storage, and we are done.

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

The code is practically the same as before: We create a transaction, query the state 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 we don't need to change the network, but it is a minor thing. It does sound strange (and pointless) to allow the State Connector to be used on the same network, but the main improvement comes from the toplevel relayer coming in the FSP.

Once the State Connector is included in the toplevel protocol, any State Connector data is immediately relayed to externally connected chains via relay (as is the FTSO data) and that means that external chains can also observe what is happening on Flare. Think about this: Up until now, we 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).