Contents

Ethereum RPC Compatibility for Polkadot Smart Contracts

The idea of making pallet-contracts Ethereum RPC compatible has been around for a while. I did some research on the topic and built a working prototype which can be found in this repo. This post describes briefly the approach taken to the problem, the results so far and possible vectors of further development.

Rationale

We have the most technically developed blockchain platform, but lack of real users. The largest user base stays in Ethereum. So the ultimate objective of this project is to empower onboarding these users to our ecosystem. One obvious way of doing so is to migrate the beloved and popular Ethereum dapps onto Polkadot. This basically means to give the same user interfaces and UX, while providing cheaper and faster execution layer, with further possibly unlocked richer functionality, thanks to cross-chain interactions like XCM, bridges, etc.

Technically-wise, there is nothing special in Ethereum what can’t be built on Polkadot, which provides way more features and agility to developers. Therefore, in essence there is (theoretically) no insurmountable constraints for porting Ethereum dapps (read: their users) to our ecosystem. What we need to deal with here is user inertia. The less user has to change in her basic journey experience, the better for getting her come here.

At this point a meticulous reader may say: well, we’ve already been through this. We even have fully compatible EVM parachains. Where users?

Well, true, there is a solely EVM-based parachain. Still it looks like just porting EVM to Polkadot (with performance downsides) is not really enough to make a breakthrough. Our contracts engine had proven to be faster, and going to become ultra faster with coming support for the new ISA. We want to leverage this advantage to attract users. Yes, we also got a parachain having both pallet-contracts and pallet-evm aboard. But while it tends to provide interoperability between contracts running on these separate engines, from the user perspective this is not going to bring more performance. And from the developer perspective it makes apps much more complicated to build.

The idea of the project introduced here is to make Polkadot dapps Ethereum RPC compatible without taking the EVM part of the deal. Let’s have a look on how’s that possible.

Compatibility Decoded

Consider a typical dapp tech stack:

Simply put, there are the following layers of it:

  • UI built with web3js libraries,

    which speaks to a node via its RPC;

  • RPC exposed by a chain node,

    which speaks to node’s runtime via its API;

  • EXEC Contracts execution engine,

    implemented as a runtime module;

  • CHAIN protocol logic,

    implemented in other runtime modules.

When we want to port an Ethereum dapp to our Substrate-based chain, we need to make sure that:

  1. We expose the RPC endpoint which speaks to the UI in compliance with the Ethereum RPC spec.

  2. On the Execution layer, this implies that our engine should allow contracts to implement the same business logic as in EVM contracts, while keeping the same calling conventions between caller and callee (more on that in the next section).

  3. Aside from input and output of the contract being called, dapp might rely on the underlying chain protocol data, such as block and transaction data, gas prices, storage state, etc.

    For our RPC to provide such data, our runtime should have logic for translating Substrate chain data to Ethereum chain data. (Right away this point could sound like a deal breaker. But hold on, things might not be so bad. Keep reading to the next section.)

Now that we have pointed out what has to be done, let’s dive into how we try to achieve this.

Assembling Compatibility

As we’ve pointed out, just exposing the Ethereum-alike looking RPC is not enough. The underlying logic should comply with it as well (at least to a certain degree).

For that the system must be able to be compatible to how things work in EVM on the layers below the RPC. First of which is the execution engine.

Execution Layer

Luckily for us, pallet-contracts module of Substrate had been designed to be in feature-parity with EVM. Both are stack-based machines with gas metering, contract accounts are of the same nature as external (user) accounts (from the chain protocol perspective), there are constructor and call messages, payable and non-payable messages, as well as support for cross-contracts calls (both context-switching and delegated ones aka libraries), and overall its API allows to implement pretty much everything one would expect from an Ethereum smart contract.

Still, there is one quite a sound discrepancy, which albeit do not really relates to the engine level. It is the way contract input data is encoded: Solidity contracts use ABI encoding, whereas ink! contracts use SCALE. But this is a language level difference. On the lower, execution level, our contracts (both Wasm and RISC-V) accept input just as a byte sequence. How it’s being treated\decoded is determined by contract intrinsic logic, meaning contract developer can implement it in the way so that it works with ABI encoded data. Of course this would not be a very handy thing to do from the developer point of view, given no special tooling provided for that. But what’s important is that it is not an insurmountable obstacle, and we will talk a bit on how to deal with that further in this post.

Next compatibility things refer to chain-specific data.

Chain Layer

Here it comes to the most noticeable difference between Polkadot and Ethereum building blocks: account format and crypto primitives:

  • Normally in Polkadot we use 32-bytes-long AccountId accompanied with Schnorrkel/Ristretto sr25519 algorithm for keys and signing, and blake2 for hashing.
  • Ethereum standard is 20-bytes-long AccountId and ECDSA secp256k1 key pairs in combination with keccak256 hash function.

There are more other discrepancies like e.g. the fact that extrinsic Id is not guaranteed to be unique between blocks in Substrate-based chains, while for Ethereum transactions that’s the fact on which a good part of business logic relies upon. Also Ethereum has just gas for measuring computational effort, whereas in Polkadot we have two-dimensional weight, counting for execution time and for implied size of the proof data used by PVF. There are more other issues, but all of those seem to be surmountable (at least of this point of the research).

As per AccountId, well… Polkadot was designed so that you can customize everything, and that unlocks some opportunities. First, nothing restricts your parachain from using whatever AccountId and elliptic curves you want in your runtime’s business logic. More to that, polkadot-sdk provides ready to use Ethereum crypto primitives for this.

Second, here we have to turn back to the objective we set at the beginning of this post, which is to get existing Ethereum users aboard. And then we have to admit that users are not going to switch to other wallets/signing extensions right away, overnight. That means that for account format and keypairs we have to allow them keeping what they currently use.

What we have listed in this section is not an exhaustive set of possible compatibility issues on the chain level, there might be others. For now in ethink! some of RPC methods related to chain data are mocked, others return Substrate chain data. With the contracts tested so far, this looks like not a problem. In general, it should not be a show stopper, as in the worst case we could construct and store a fake Ethereum block for every Substrate block, the approach seems to work fine in Frontier-based chains. But again, the research is ongoing, and this would need to be tested in practice with porting real Ethereum dapps onto this solution.

Finally, we can’t just wave a magic wand and solve the problem for all parties (users and developers) at once. Then let’s break it into steps, the roadmap of which could look like the one presented in the following section.

The Road To All-Hands Compatibility

As a first step, in short-term we may try to change as less as possible for the user, which naturally comes with the cost of additional work to be done by the dapp developer. In particular, to deal with ABI/SCALE encoding mismatch, as well as different message selectors, the frontend piece of the dapp would have to be modified to work with the a_contract.ink metadata instead of a_contract.sol one. Or, the contract piece of the dapp would have to be taught to deal with ABI encoding so that it takes the same input from the caller and returns the same output as the original Solidity contract does. In any case, unless we have a tooling for those things, the contract developer would have to re-write his Solidity contract in ink! by hand, which is totally feasible thing to do, as ink! was designed to have similar contract layout to Solidity, and if you look at its basic examples, you find a number of such ported contracts.

Good news are that the tools for automation of such a translation are in active development: there is solang compiler. I’m not sure if we are there yet, but once it’s ready, we take the second step, and let dapp developer just re-compile a Solidity contract source to be deployed to pallet-contracts.

Last but not the least, some helpful tooling for developers, either for the ease of porting the frontend piece of dapp, or for making a contract deal with ABI-encoded input, could also possibly be provided. (At the end of the day, ink! contracts are basically Rust code, so chances are you could possibly use some existing crates for that).

What’s being said could be summarized to the table:

Currently we are at the first column, and it’s feels like starting entering the second one (need to sync with @Cyrill and do some experiments with solang).

Following this narrative, we may envision the target scenario as follows:

Target Scenario:

  1. Take a successful Ethereum dapp.

  2. Transcompile its contract from Solidity to Wasm/PolkaVM’s RISC-V.

  3. [might not be needed] Port dapp frontend so that it deals with new metadata.

    From [ABI encoded input + selector] to [SCALE encoded input + selector].

    (As mentioned before, this step might not be needed, ideally could be solved on the previous step.)

  4. Deploy the contract to a parachain having ethink! aboard.

  5. Profit!!

Now that we explained how we deal with particular compatibility issues, let’s have a look on how we bring all the pieces together to a running chain node which has pallet-contracts and works with Metamask.

High-level Design

In a nutshell, ethink! is like Frontier, but for pallet-contracts instead of pallet-evm.

Here is the simplified (non-exhaustive) components map to the compatibility layers introduced in the beginning of this post:

There are 3 main pieces of it:

  • RPC The RPC “frontier”, exposing the RPC endpoint which looks just like a normal Ethereum RPC.

    Its function is to accept requests and decode them, then call an appropriate method of the API exposed by our Substrate runtime, and response back to the caller. By adding this piece to our node, we make it look like an Ethereum node to the caller, which is normally a dapp’s frontend.

  • GLUE The Runtime of our node, which provides special methods in its exposed API, gets a call from the RPC “frontier” and routes the call further.

    Some of them, like e.g. account balance checks or contract “dry-run” calls, does not bring any state changes. For such cases it just calls the corresponding pallet API method (like pallet_balances::free_balance()).

    For the ones that do change state (called transactions in Ethereum and extrinsics in Polkadot), the mechanics is as follows. The incoming Ethereum transaction data is being wrapped into UncheckedExtrinsic with the call to pallet-ethink::transact(), which decodes passed in Ethereum transaction, and routes the call further (based on type of the destination account) to a specific destination pallet. Specifically, for the calls addressed to an account which belongs to a contract, the destination module is pallet-contracts. If the callee address is a user account, the destination module is pallet-balances, as the call is considered to be just a balance transfer. (This logic is inherited from Ethereum).

    The wrapper extrinsic is being put to the transaction pool. Just like any other extrinsic, it has its special logic for checking its validity. Upon such validation, the Ethereum signature is being checked and the caller account is being extracted from it. The further way for the extrinsic is no different from any other extrinsic on its way to execution and inclusion into a block.

  • EXEC Execution of the contract happens in pallet-contracts module as usual.

No Upstream Changes

For all this stuff to work, no customization is required neither for pallet-contracts nor for ink!. Polkadot-sdk overall, and its smart contracts stuff in particular, was designed with quite a good level of abstraction, allowing building such compatibility layers on top of it, with no tightly coupling to a particular execution environment. (And this is what makes ethink! different to Frontier, which is tightly coupled to pallet-evm.)

When it comes to tooling, in particular cargo-contract (and subxt), has got some customization which makes it work with Ethereum accounts and signing, as well as to speak with ethink! node in the same language (for that chain metadata was updated).

Worth mentioning here, as “ethink!” name could be a little confusing, that this solution is not solely for ink! contracts. It is basically language-agnostic, and low coupled with the executor (meaning it should work both with Wasm and RISC-V -flavored pallet-contracts, and could be adopted to other embedders\executors if someday we will have new options for that). Still the most mature contracts language in our ecosystem is ink!, and it’s basically the only feasible one for production use so far. That’s why the project naming was made with an accent on it.

Current State

The Prototype includes a Substrate node with pallet-contracts aboard and exposed Ethereum RPC. You can write your contracts in ink!, build them and deploy to the node using the fork of cargo-contract tool. Then you can interact with the contracts via Metamask. Follow the guidelines written in the project README file to try it out.

ink! contracts have to use custom environment with 20-bytes-long account addresses, see the example contract on how to do that. Currently there are two working examples, Flipper and ERC20, with more to be added in the near future.

There is also a little end-to-end testing framework, and the examples are covered with integration tests. Having a look at them might help to understand how input data is expected to be encoded on the caller side, and shed some light on the overall workflow of communicating with the node.

Please feel free to play with the prototype, and give your feedback here to the comments, or to the project repository issues.

What’s Next

The possible next steps are:

  1. Update dependencies to recent polkadot-sdk.

    Currently it uses polkadot-v1.1.0, which is bit outdated. There is no reason not to update to the recent release (just didn’t get around to it yet).

  2. Update examples to ink! v5.

  3. Deploy this prototype to a testnet.

    Most likely this will be Yerba Network.

  4. Choose a good Ethereum dapp and try to port it to the testnet.

    The goal is to make a showcase, and a reality test for this prototype. Expect chaos (c).

    Would it be really possible? Would it be faster? And seamless from the user experience perspective? Well, I’m looking forward to figure this out.