ChainSyncer cookbook

ChainSyncer cookbook

A collection of recipes for using the ChainSyncer module

As promised in the last article, I will write about some best practices for using the ChainSyncer module.

Maximum Consistency

Due to the asynchronous nature of the module and the overhead of most RPC servers, some tricks are required when working with the module in order to maintain the consistency with the blockchain.

Here is an example: We have an abstract staking contract.

// SPDX-License-Identifier: MIT

pragma solidity 0.8.17;

/**
  * Some very-very abstract staking contract without any sense
  */
contract Staking {

  struct Pool {
    uint256 total_staked;
  }

  mapping(uint256 => Pool) pools;

  event Staked(
    uint256 indexed pool_id,
    address staked_by,
    uint256 current_total_staked
  );

  function stake(uint256 pool_id_, uint256 amount_) external {
    pools[pool_id_].total_staked += amount_;

    emit Staked(pool_id_, msg.sender, pools[pool_id_].total_staked);
  }
}

The task is to track the number of tokens in the pool under conditions of high contract load (let’s say it is very popular).

We will do this with the help of the Staked event.

// Example using Mongoose
// This is just an abstract example, dont try to copy-paste it

const Mongoose = require('mongoose');
const { ChainSyncer } = require('chain-syncer');
const { MongoDBAdapter } = require('@chainsyncer/mongodb-adapter');

// where will we store the pulled events
await Mongoose.connect(process.env['MONGO_SRV']);

const adapter = new MongoDBAdapter(Mongoose.connection.db);
const syncer = new ChainSyncer(adapter, { /* ... some options ... */ })

syncer.on('Staking.Staked', async (
  { global_index }, // will need in the next example
  pool_id,
  staked_by, // actually dont need
  current_total_staked,
) => {

  await StakingPool.updateOne({
    _id: pool_id,
  }, {
    $set: {
      total_staked: Number(current_total_staked),
    },
  });

});

It is important to ensure that our handlers are idempotent. This is possible only if you design events in such a way that they store the current data snapshot: in our case, total_staked. In contrast, if we store amount_, then we will have to go through all the events just to find out how much is currently staked. This will greatly complicate the code.

A rather serious bug hid in the code above. Since the module processes events in parallel in batches (for maximum performance), it is likely that an earlier event will be processed after a later event. As a result, total_staked on your backend will be less than it should be.

image

This is where the global_index parameter comes into play. With it, we can determine the most current total_staked state of the pool.

image

By making some changes to our handler, we can remove the risk of synchronization errors:

syncer.on('Staking.Staked', async (
  { global_index }, // we need only global_index
  pool_id,
  staked_by, // actually dont need
  current_total_staked,
) => {

  await StakingPool.updateOne({
    _id: pool_id,

    // we need to update total_staked only if global_index is greater than the last one saved
    last_synced_at: { $lt: global_index },
  }, {
    $set: {
      total_staked: Number(current_total_staked),

      // dont't forget to save
      last_synced_at: global_index,
    },
  });

});

You are probably wondering: Where does this parameter come from? It is collected by ChainSyncer for each event by merging blockNumber and logIndex.

image

Thus, we get something like an incremental unique ID for each event.


Postponement of Events

Another feature designed to help deal with module asynchrony problems is saving the sequence of event processing.

I’ll give you an example. You have a sword object for your crypto game that is stored entirely in a contract.

// SPDX-License-Identifier: MIT

pragma solidity 0.8.17;

contract Swords {

  struct Sword {
    uint256 damage;
    bool is_enchanted;
  }

  Sword[] public swords;

  event Created(
    uint256 sword_id,
    uint256 damage
  );

  event Enchanted(
    uint256 sword_id
  );

  function _create(uint256 damage_) internal returns (uint256) {

    uint256 sword_id = swords.length;
    swords.push(Sword(damage_, false));

    return sword_id;
  }

  function create(uint256 damage_) external {

    uint256 sword_id = _create(damage_);

    emit Created(sword_id, damage_);
  }


  function createAndEnchant(uint256 damage_) external {

    uint256 sword_id = _create(damage_);
    swords[sword_id].is_enchanted = true;

    emit Created(sword_id, damage_);
    emit Enchanted(sword_id);
  }
}

We also have a backend that keeps track of the status of each sword in the contract.

syncer.on('Swords.Created', async (
  { global_index, block_timestamp }, // don't need
  sword_id,
  damage
) => {

  // uint256 that comes from event syncer converts to string, but we need a number
  sword_id = Number(sword_id);
  damage = Number(damage);

  // getting the sword by id 
  const sword = await Sword.findOne({ _id: sword_id });

  // will create a new one, if no sword found in our DB
  if(!sword) {

    await Sword.create({
      _id: sword_id,
      damage: damage,
      is_enchanted: false,
    });
  }

});


syncer.on('Swords.Enchanted', async (
  { global_index, block_timestamp }, // don't need
  sword_id,
) => {

  sword_id = Number(sword_id);

  await Sword.updateOne({
    _id: sword_id,
  }, {
    $set: {
      is_enchanted: true, // updating owner address
    }
  });

});

As I described above, events are processed in parallel, and in this example, we need a clear sequence of execution of handlers. Since Enchanted and Created are thrown in the same transaction, there is an extremely high risk that the Enchanted event will be processed before the Created event, which will lead to a loss of consistency.

To eliminate the possibility of this bug, ChainSyncer has a civil mechanism for postponing events: if false is returned out of the handler, then the event processing is postponed for the next tick.

syncer.on('Swords.Enchanted', async (
  { global_index, block_timestamp }, // don't need
  sword_id,
) => {

  sword_id = Number(sword_id);

  // getting the sword by id 
  const sword = await Sword.findOne({ _id: sword_id });

  // if no sword found in our DB, 
  // we will postpone this event till the sword will be created
  if(!sword) {
    return false;
  }

  await Sword.updateOne({
    _id: sword_id,
  }, {
    $set: {
      is_enchanted: true, // updating owner address
    }
  });

});

Using this mechanism, we get something like an event dependency graph.

That’s all for now. By using these practices, you will get both high performance and reliable consistency, so you can sleep soundly.

In the next article I will tell you about more in-depth work using the module — scanning individual network blocks, scanner mode, and maybe something more.


There is still a war going on in Ukraine and at this moment the support of each and everyone is really important. Every dollar strongly brings Ukraine closer to victory 🇺🇦. How can you help.