Ion Stage 2: Cross-Chain Smart Contract Development (Part 4)

In this fourth and final part in our blog series we delve into the details of how to develop cross-chain interacting smart contracts that leverage the Ion framework .

Parts 2 and 3 of this series explored how we’ve designed and constructed the Ion framework to facilitate a linkage and dependence on state and events between different blockchains and blockchain systems. The framework sets up the environment with all the data we need to make assertions regarding a transaction and its associated state transition providing a simple interface to make these proofs.

How can we utilise the interface to plug in our own smart contracts to interact across chains? We aim to execute a smart contract function call if (and only if) we can correctly assert that an expected event existed in a certain transaction in a given block from another chain. The interface provided by our Block Store contract allows basic access to a block to make these checks and return the result. Naïvely, we simply need to pass a proof to the function, check this proof against the block held by querying the Block Store contract, and continue to execute the function if the proof verification returns true and otherwise, revert.

This is the basic flow of how a smart contract would interact with the Ion framework. But there are a few more details that need to be considered when implementing such smart contracts.

Determining a Use case

Knowing the purpose of a cross-chain interacting smart contract and the way it interacts with other contracts on other chains is paramount to beginning the development. Firstly we must know the functionality of our use-case, how it integrates with the ecosystem and whether it will require interaction with other smart contracts. From this, we begin to understand what existing external components we will be interacting with and how these will impact our design. If you’re not intending to interact with already existing smart contracts and only intending to interact with different deployments of your contract, then this is an easier task. However we first must know what events our contract will consume and what functionality we intend to execute upon consumption.

Maybe it’s an atomic swap contract, or maybe it’s a conditional payment, in any case we want to do something if a specific transaction has performed an expected state transition on another chain.

Sketching the skeleton

Knowing what we intend to do is just the beginning. Now we must start generating our functional skeleton. To assert a specific state transition through the consumption of an event we must first know the event signature, and decode it in order to verify the expected parameters. This is what allows us to derive dependence upon the transaction by expanding the contents of it to obtain metadata about its execution. Otherwise, all data is obfuscated behind a single transaction hash that tells us nothing of what actually occurred. Decoding events allows us to check the parameters of them as part of our verification procedure to ensure what happened and the event we’re consuming is what we expect, before we execute our own methods.

Now we have a list of relevant events that we intend to consume, we can start thinking about the user interface to the smart contract and which functions will be exposed for interaction and what events they consume. For the sake of simplicity, let’s say we want to develop a smart contract in solidity, Function.sol, with a single function, execute, that we want to call after the consumption of an event. The event being consumed has a signature of Triggered(address) where the address in the event parameter is the caller of the function that emitted the event. For each function we wish to create that consumes an event, this procedure is an important part of the process in understanding what events it consumes (i.e. how it interacts or draws dependence on other contracts) and what functionality we are implementing based on it.

Event Verifiers

The general structure and high-level interactions are now known, but before writing the smart contract, we are missing a vital part of the puzzle. In order to consume events we need event verifiers. What are event verifiers?

Event verifiers are smart contracts that provide an interface to decode and check the parameters in an event of a certain type. We’ve identified the set of events that we intend on consuming, and consumption consists of a verification procedure of an event existing in a transaction with the expected parameters. In order to perform this check we must be able to decode the events as mentioned before, and to do this we use the event signature. With this event signature we can filter the receipts of a transaction to return the specific event we care about, if it exists. Once we’ve filtered, we can use the returned event to check the data contained in the parameters against any expected values to ensure that the transaction did or did not do something relevant to us.

Each event type we intend to consume needs to have its associated event verifier to decode and verify it. Since they are event signature specific, the same verifier may be used by multiple different use-case contracts that require the consumption of an event with that signature. Thus a verifier of a certain event type only needs to be written once and its usage shared amongst those that require it. What does an event verifier look like?

Base Verifier

Below is the base event verifier class that all event-specific verifiers should inherit from. This provides the required retrieveLog function that all event verifiers will use to filter, from a set of RLP-encoded receipts, the relevant event by signature whilst asserting that the event was emitted from a contract origin expected.

import "./libraries/RLP.sol";
import "./libraries/SolidityUtils.sol";
contract EventVerifier {
function retrieveLog(
bytes32 _eventSignature,
bytes20 _contractEmittedAddress,
bytes _rlpReceipt
)
internal returns (RLP.RLPItem[])
{
RLP.RLPItem[] memory receipt =
RLP.toList(RLP.toRLPItem(_rlpReceipt));
RLP.RLPItem[] memory logs = RLP.toList(receipt[3]);
        for (uint i = 0; i < logs.length; i++) {
RLP.RLPItem[] memory log = RLP.toList(logs[i]);
RLP.RLPItem[] memory topics = RLP.toList(log[1]);
            bytes32 containedEventSignature = 
RLP.toBytes32(topics[0]);
if (containedEventSignature == _eventSignature) {
bytes20 b20_emissionSource =
SolUtils.BytesToBytes20(RLP.toData(log[0]), 0);
assert( b20_emissionSource ==
_contractEmittedAddress);
return log;
}
}
assert( false );
}
}

Event-specific Verifiers

Event-specific verifiers are specific implementations of verifiers for each individual event structure to be consumed. This is required to encode the way that an event should be deconstructed for proofing. All event-specific verifiers should inherit the base EventVerifier contract and keep an immutable copy of the event signature it pertains to which identifies what event each verifier intends to consume. For our example, where we will consume an event with a signature of Triggered(address), the below is an implementation of an event verifier for this.

import "./libraries/RLP.sol";
import "./libraries/SolidityUtils.sol";
import "./EventVerifier.sol";
contract TriggerEventVerifier is EventVerifier {
bytes32 eventSignature = keccak256("Triggered(address)");
    function verify(
bytes20 _contractEmittedAddress,
bytes _rlpReceipt,
bytes20 _expectedAddress
) public returns (bool) {
// Retrieve specific log for given event signature
RLP.RLPItem[] memory log =
retrieveLog(
eventSignature, _contractEmittedAddress, _rlpReceipt
);
        // Split logs into constituents. Not all constituents are used here
bytes memory contractEmittedEvent = RLP.toData(log[0]);
RLP.RLPItem[] memory topics = RLP.toList(log[1]);
bytes memory data = RLP.toData(log[2]);
        bytes20 b20_address = 
SolUtils.BytesToBytes20(data, data.length - 20);
assert( b20_address == _expectedAddress );
        return true;
}
}

As shown, we have a local variable that encodes which event signature this verifier cares about and then uses it as part of the retrieveLog function call to retrieve the RLP-encoded event in the receipts. Our event-to-be-consumed contains a single parameter address. We want our use-case function to execute only if a certain address had called that function and emitted the event. Here we assert that the address contained in the event is indeed one that we expected as passed into the verify function as _expectedAddress. We also assert the source contract address that emitted the event. Since different contracts can emit identical events, part of our check must ensure we are consuming the event from the correct place.

Use-case Implementation

Now that we have all our necessary contracts, we can start building our use-case contract. We know that our contract needs to fulfil some functionality:

  • Must execute a function upon successful consumption of expected event
  • Must pass proofs and expectations to EventVerifier(s) to consume event
  • Must pass proofs to Block Store contract to assert transaction and receipt exist in block

Therefore our contract must be able to access:

  • Block Store contract for transaction and receipt checking
  • All relevant EventVerifiers for event consumption

So our initial naïve contract structure would look like the following:

contract TriggerEventVerifier {
function verify(
bytes20 _contractEmittedAddress,
bytes _rlpReceipt,
bytes20 _expectedAddress
) public returns (bool);
}
contract Function is IonCompatible {
TriggerEventVerifier verifier;
    constructor(address _ionAddr, address _verifierAddr)
IonCompatible(_ionAddr) public
{
verifier = TriggerEventVerifier(_verifierAddr);
}
}
contract IonCompatible {
Ion internal ion;
    constructor(address ionAddr) public {
ion = Ion(ionAddr);
}
}

Where Ion is the Block Store contract. We also need to provide a contract interface for the event verifier so it has knowledge of the methods when attempt to make calls to it after creating a local reference.

Next we can add our function that will execute upon correct checking of all requisites.

event Executed();
function execute() internal {
emit Executed();
}

This execute function simply emits an event to show its execution. However, we only want the function to execute once we’ve successfully made all checks. This is the hard bit.

In order to make proofs against the existence of a transaction and a receipt in a block there needs to be certain data submitted:

  • RLP-Encoded Tx object of the transaction to be checked
  • RLP-Encoded set of relevant Txs as part of the merkle patricia proof of the Tx
  • Path to the Tx in the trie, as an RLP-encoded transaction index of the Tx. The receipt shares this path.
  • RLP-Encoded Receipt object of the transaction to be checked
  • RLP-Encoded set of relevant Receipts as part of the merkle patricia proof of the receipt
  • The Block Store contract holds both Tx trie roots and receipt trie roots. Using the proof data submitted, we can perform merkle patricia trie proofs against those trie roots with the data supplied to assert if the transaction exists in the Tx trie root and if the receipt exists in the receipt trie root.

We must therefore add a verification function that accepts proofs and can use them to verify against the data held in the blocks:

function verifyAndExecute(
bytes32 _chainId,
bytes32 _blockHash,
bytes _path,
bytes _tx,
bytes _txNodes,
bytes _receipt,
bytes _receiptNodes
) public returns (bool) {
assert( ion.CheckRootsProof(
_chainId,
_blockHash,
_txNodes,
_receiptNodes)
);
assert( ion.CheckTxProof(
_chainId,
_blockHash,
_tx,
_txNodes,
_path)
);
assert( ion.CheckReceiptProof(
_chainId,
_blockHash,
_receipt,
_receiptNodes,
_path)
);
    execute();
}

Now we need to make event consumption checks against the event verifier with our expected data.

function verifyAndExecute(
bytes32 _chainId,
bytes32 _blockHash,
bytes20 _contractEmittedAddress,
bytes _path,
bytes _tx,
bytes _txNodes,
bytes _receipt,
bytes _receiptNodes,
bytes20 _expectedAddress
) public returns (bool) {
assert( ion.CheckRootsProof(
_chainId,
_blockHash,
_txNodes,
_receiptNodes)
);
assert( ion.CheckTxProof(
_chainId,
_blockHash,
_tx,
_txNodes,
_path)
);
assert( ion.CheckReceiptProof(
_chainId,
_blockHash,
_receipt,
_receiptNodes,
_path)
);
    if ( verifier.verify(
_contractEmittedAddress,
_receipt,
_expectedAddress)
) {
execute();
return true;
} else {
return false;
}
}

Our final contract should then look like:

contract Function is IonCompatible {
TriggerEventVerifier verifier;
    constructor(address _ionAddr, address _verifierAddr) 
IonCompatible(_ionAddr) public
{
verifier = TriggerEventVerifier(_verifierAddr);
}
    event Executed();
function execute() internal {
emit Executed();
}
    function verifyAndExecute(
bytes32 _chainId,
bytes32 _blockHash,
bytes20 _contractEmittedAddress,
bytes _path,
bytes _tx,
bytes _txNodes,
bytes _receipt,
bytes _receiptNodes,
bytes20 _expectedAddress
) public returns (bool) {
assert( ion.CheckRootsProof(
_chainId,
_blockHash,
_txNodes,
_receiptNodes)
);
assert( ion.CheckTxProof(
_chainId,
_blockHash,
_tx,
_txNodes,
_path)
);
assert( ion.CheckReceiptProof(
_chainId,
_blockHash,
_receipt,
_receiptNodes,
_path)
);
        if ( verifier.verify(
_contractEmittedAddress,
_receipt,
_expectedAddress)
) {
execute();
return true;
} else {
return false;
}
}
}

Since we need to assert the correct emission source and any expected event parameter values, these are extra data that need to be passed to the verification function. As shown, a single function will query the Block Store contract to assert the state transition exists, then consume the contained relevant event with expected data and then execute some required process once they all pass. With this we can encode any behaviour to derive dependence on the state from another chain.

The interfaces provide easy access to both state transition verification of a block but also easy event decoding and parameter checking.