Feb 7, 2024

Behind Chainsight: A Look Into Relayer Component

by Megared

In the previous Deep Dive into Component, we discussed Snapshot Indexer. In this second article, we will discuss Relayer.

What is Relayer?

Component to combine Snapshot Indexer and other Components to synchronize propagated data and calculated indices to the outer blockchain. It takes one Component in the Chainsight Platform as its data source, periodically fetches data from it, creates transactions to a specified blockchain contract, and attempts to synchronize that data.

chainsight_deepdive_into_showcase-relayer.png

How to create Relayer?

The manifest for building Relayer consists mainly of a datasource field that specifies the Component in the Chainsight Platform and a destination field that specifies the blockchain and contract to which the data will be synchronized.

In the datasource field, specify the id of the Component to be the data source and its function interface. The function interface uses Candid, which is common in Internet Computer's Canister.

https://internetcomputer.org/docs/current/developer-docs/backend/candid/candid-concepts

Candid allows for custom type definitions, which can also be automatically interpreted by setting the .did file itself to be read.


datasource:
  location:
    id: icrc1_canister
  method:
    identifier: 'icrc1_balance_of : (Account) -> (nat)'
    interface: interface/ICRC-1.did
    args: []

The destination field specifies the blockchain to be synchronized and the contracts deployed there. Synchronization destinations can be configured with minimal settings such as network_id to identify the blockchain and rpc_url and address, which are the endpoints used in the call.

destination:
  network_id: 11155111
  type: uint256
  oracle_address: 0xB5Ef491939A6dBf17287666768C903F03602c550
  rpc_url: https://ethereum-sepolia.blockpi.network/v1/rpc/public

How to use Relayer

Once the component starts working, there is nothing for the user to do in order to collect the data to be synchronized and send it to the specified destination. The component will execute periodically at the interval you specify.

# manifest for relayer
...
interval: 3600

NOTE: In the blockchain targeted for synchronization, the contract whose Relayer is sender(/signer) must retain the gas price. Currently, this one needs to be added manually. Use get_ethereum_address to check the address and replenish it.

get_ethereum_address : () -> (text);

Please also refer to the following article for the actions required here.

https://dev.to/hide_yoshi/step-by-step-creating-an-evm-price-oracle-with-chainsight-469g

Behind Relayer

We have described how users create and work with Relayer. Let's take a deeper look at this Component to better understand it so that we can better handle Relayer. The general framework of the code generated by the macro that builds Relayer's code is the following flow.

#[ic_cdk::update]
#[candid::candid_method(update)]
async fn index() {
	// Get data from datasource

  // Preparing to send a transaction

  // Send a transaction (with setting gas,nonce & signing tx)
}

After implementing the appropriate logic in this function, the mechanism is designed to be executed periodically.

Data acquisition is the same as the method used in the Snapshot Indexer ICP introduced in the previous article Snapshot Indexer; data is acquired by making a cross canister call to the specified component in Chainsight. The mechanism for sending transactions to the EVM-compatible blockchain, which will be explained in the following sections, is unique to Relayer.

To begin with, a transaction in an EVM compatible blockchain is a request to update the status of a contract in that blockchain.

Transactions, which change the state of the EVM, need to be broadcast to the whole network. Any node can broadcast a request for a transaction to be executed on the EVM; after this happens, a validator will execute the transaction and propagate the resulting state change to the rest of the network.

Source: https://ethereum.org/en/developers/docs/transactions#whats-a-transaction

Relayer periodically generates and sends transactions, allowing the status of a particular contract to be updated at regular intervals. This allows the contract to be treated as an Oracle contract.

How does the component send transaction

Generating a legitimate transaction requires going through several difficult processes. It is necessary to know the current gas situation, estimate the gas to be consumed, set the value, and sign the data you want to send using your own private key.

Gas is a reference to the computation required to process the transaction by a validator. Users have to pay a fee for this computation. The gasLimit, and maxPriorityFeePerGas determine the maximum transaction fee paid to the validator. More on Gas.

But a transaction object needs to be signed using the sender's private key. This proves that the transaction could only have come from the sender and was not sent fraudulently.

Source: https://ethereum.org/en/developers/docs/transactions#whats-a-transaction

We have created ic-web3-rs, which generalizes these processes for Internet Computer, and ic-solidity-bindgen, which uses it internally to automatically generate code from ABI to call contracts and functions of those contracts. This makes it easy to build Relayer with built-in transaction creation and generation capabilities.

Let's look at the code to generate/send the transaction. All that is needed to generate this code is the ABI for that contract and a line of code. Let us look at ERC20 as an example.

contract_abi!("./sample/abi/ERC20.json");

This is a macro in ic-solidity-bindgen. It provides a set of functions to call the contract from an Internet Computer using a JSON file, which is an Contract ABI, as input.

If we look at the code generated to perform that transfer function in the ERC20 case…

pub struct ERC20<SolidityBindgenProvider> {
    pub provider: ::std::sync::Arc<SolidityBindgenProvider>,
    pub address: ::ic_web3_rs::types::Address,
}
...
impl<SolidityBindgenProvider> ERC20<SolidityBindgenProvider>
where
    SolidityBindgenProvider: ::ic_solidity_bindgen::SendProvider,
{
    ...
    pub async fn transfer(
        &self,
        to: ::ic_web3_rs::types::Address,
        amount: ::ic_web3_rs::types::U256,
        options: Option<::ic_web3_rs::contract::Options>,
    ) -> ::std::result::Result<SolidityBindgenProvider::Out, ::ic_web3_rs::Error> {
        self.provider.send("transfer", (to, amount), options, None).await
    }
    ...
}

The function "send" of the set generics struct is called internally. I won't go into the actual structure settings used for this generics struct, but if you look at the code for this send,

#[async_trait]
impl SendProvider for Web3Provider {
    type Out = TransactionReceipt;
    async fn send<Params: Tokenize + Send>(
        &self,
        ...
    ) -> Result<Self::Out, ic_web3_rs::Error> {
        let canister_addr = ethereum_address(self.context.key_name().to_string()).await?;
        let call_option = match options {
            None => {
                let gas_price = self
                .with_retry(|| self.context.eth().gas_price(CallOptions::default()))
                .await?;
                let nonce = self
                .with_retry(|| {
                    self.context
                        .eth()
                        .transaction_count(canister_addr, None, CallOptions::default())
                })
                .await?;
                Options::with(|op| {
                    op.gas_price = Some(gas_price);
                    op.transaction_type = Some(U64::from(2)); // EIP1559_TX_ID for default
                    op.nonce = Some(nonce);
                })
            },
            Some(options) => options,
        };

        self.contract
            .signed_call_with_confirmations(
                ...
            )
            .await
    }
}

Get the gas price, get the transaction count, create the parameters, and sign and send with contract.signed_call_with_confirmations. The logic for the individual processes described above is implemented in ic-web3-rs. The library has functions that wrap EVM's JSON-RPC API, as well as computational logic for signatures, which is used by ic-solidity-bindgen. In fact, to set the parameters this time, functions are used to call the eth_getTransactionCount and eth_gasPrice endpoints.

/// Get nonce
pub fn transaction_count(
    &self,
    address: Address,
    block: Option<BlockNumber>,
    options: CallOptions,
) -> CallFuture<U256, T::Out> {
    let address = helpers::serialize(&address);
    let block = helpers::serialize(&block.unwrap_or(BlockNumber::Latest));

    CallFuture::new(
        self.transport
            .execute("eth_getTransactionCount", vec![address, block], options),
    )
}

/// Get current recommended gas price
pub fn gas_price(&self, options: CallOptions) -> CallFuture<U256, T::Out> {
    CallFuture::new(self.transport.execute("eth_gasPrice", vec![], options))
}
pub async fn signed_call_with_confirmations(
    &self,
    ...
) -> crate::Result<TransactionReceipt> {
    let poll_interval = time::Duration::from_secs(1);
    let signed = self
        .sign(func, params, options.clone(), from, key_info, chain_id)
        .await?;

    confirm::send_raw_transaction_with_confirmation(
        ...
    )
    .await
}

The code created by making full use of the libraries introduced so far is used in the SDK macro to generate code that sends the data to be synchronized to the contract in this way.

ic_solidity_bindgen::contract_abi!("__interfaces/Uint256Oracle.json");
...
#[ic_cdk::update]
#[candid::candid_method(update)]
async fn index() {
	...

  let result = Uint256Oracle::new(
      Address::from_str(&get_target_addr()).expect("Failed to parse target addr to Address"),
      &web3_ctx().expect("Failed to get web3_ctx"),
  )
  .update_state(
      chainsight_cdk::web3::abi::EthAbiEncoder.encode(datum.clone()),
      call_option,
  )
  .await
  .expect("Failed to call update_state for oracle");
	
	...
}

By such an implementation, Relayer obtains the data to be synchronized via an Inter-Canister Call on the Internet Computer and sends the signed transaction to the synchronization destination.

What is the private key of a contract in Relayer?

Internet Computer has a threshold ECDSA protocol that allows each Canister to maintain a unique ECDSA public key. As the name implies, an individual canister never possesses a private key, and only the canister who is supposed to be the holder of the key can request a signature.

the private ECDSA key exists only as secret shares held by designated parties, namely the replicas of a threshold-ECDSA-enabled subnet on ICP, and signatures are computed using those secret shares without the private key ever being reconstructed.

Each canister on any subnet of the Internet Computer has control over a unique ECDSA public key and can request signatures for this public key to be computed. A signature is only issued to the eligible canister, i.e., the legitimate holder of this ECDSA key. Each canister can obtain signatures only for its own ECDSA keys. Note that canisters do not hold any private ECDSA keys or key shares themselves.

Source: https://internetcomputer.org/docs/current/developer-docs/integrations/t-ecdsa/

To request this signature, the Internet Computer Gateway has a function called sign_with_scdsa,

sign_with_ecdsa : (record {
  message_hash : blob;
  derivation_path : vec blob;
  key_id : record { curve: ecdsa_curve; name: text };
}) -> (record { signature : blob });

This is used to generate a function that performs the signing, which is used in the processing process described earlier.

// ic-web3-rs

/// use ic's threshold ecdsa to sign a message
pub async fn ic_raw_sign(message: Vec<u8>, key_info: KeyInfo) -> Result<Vec<u8>, String> {
    assert!(message.len() == 32);

    let key_id = EcdsaKeyId {
        curve: EcdsaCurve::Secp256k1,
        name: key_info.key_name,
    };
    let ic = Principal::management_canister();

    let request = SignWithEcdsaArgument {
        message_hash: message.clone(),
        derivation_path: key_info.derivation_path,
        key_id,
    };

    let ecdsa_sign_cycles = key_info.ecdsa_sign_cycles.unwrap_or(ECDSA_SIGN_CYCLES);

    let (res,): (SignWithEcdsaResponse,) =
        ic_cdk::api::call::call_with_payment(ic, "sign_with_ecdsa", (request,), ecdsa_sign_cycles)
            .await
            .map_err(|e| format!("Failed to call sign_with_ecdsa {}", e.1))?;

    Ok(res.signature)
}

I was able to introduce Relayer's important background by touching on important concepts such as Internet Computer threshold ECDSA.


In this article, I explained Relayer as part of a deep dive into Component. Look forward to the next article.