Stratis .NET Smart Contract Platform Internals

Introduction

This is the 2nd article in a series that looks at the Stratis C# smart contract platform. The first article looked at the new developer experience for C# smart contracts in IDEs like Visual Studio. In this article, we'll look at some of the smart contract platform's internals and outline the process of how a .NET smart contract gets deployed and executed on the blockchain from the point when the developer uploads the .NET smart contract bytecode to a Stratis node from a wallet like Cirrus Core.

About

Stratis is currently the only L1 blockchain and smart contract platform that is end-to-end .NET. The Stratis blockchain node is implemented in .NET and also utilizes the .NET CLR as its smart contract VM. All the tools and methods for writing, debugging, testing, and transporting .NET code can be used to develop and deploy Stratis smart contracts. In the previous article, we saw how Stratis has adapted the .NET CLR for the execution of smart contracts by relying on the processes of validation and rewriting. Validation statically analyzes .NET code to enforce constraints like determinism for executing as a blockchain smart contract. Rewriting dynamically rewrites smart contract CIL code at runtime to add capabilities like gas metering.

We first can look at the code for how smart contract execution is enabled in the smart contract feature of a Stratis node's API to get an idea of how Stratis blockchain nodes can actually execute .NET smart contracts.

The Stratis Node Smart Contract API

All the different functions of a Stratis blockchain node API are exposed via ASP.NET Core controller methods, which are cataloged using Swagger:

On a Cirrus node the API smart contract methods are all at the path /api/SmartContracts/*.

Smart contract controller methods are all defined in the SmartContractsController and SmartContractWalletController classes e.g. the /api/SmartContracts/build-and-send-call API method is implemented as:

        /// <summary>
        /// Builds a transaction to call a smart contract method and then broadcasts the transaction to the network.
        /// If the call is successful, any changes to the smart contract balance or persistent data are propagated
        /// across the network.
        /// </summary>
        ... 
        [Route("api/[controller]/build-and-send-call")]
        [HttpPost]
        [ProducesResponseType((int)HttpStatusCode.OK)]
        [ProducesResponseType((int)HttpStatusCode.BadRequest)]
        [ProducesResponseType((int)HttpStatusCode.Forbidden)]
        public async Task<IActionResult> BuildAndSendCallSmartContractTransactionAsync([FromBody] BuildCallContractTransactionRequest request)
        {
            ...
            BuildCallContractTransactionResponse response = this.smartContractTransactionService.BuildCallTx(request);
            if (!response.Success)
                return this.Json(response);
            ...

The ISmartContractTransactionService instance is injected into the controller when it is initialized and is used to handle most aspects of creating and calling smart contracts from the API. Calling the /api/SmartContracts/build-and-send-call method results in a call to the BuildCallTx method of the ISmartContractTransactionService instance to execute the smart contract, take the resulting state changes to the blockchain and bundle it into a transaction, and execute it. We can look at the code in SmartContractTransactionService to get an idea of how smart contracts are created and executed. The BuildCallTx method looks like:

public BuildCallContractTransactionResponse BuildCallTx(BuildCallContractTransactionRequest request)
{
    if (!this.CheckBalance(request.Sender))
        return BuildCallContractTransactionResponse.Failed(SenderNoBalanceError);

    (List<OutPoint> selectedInputs, string message) = SelectInputs(request.WalletName, request.Sender, request.Outpoints);
    if (!string.IsNullOrEmpty(message))
        return BuildCallContractTransactionResponse.Failed(message);

    uint160 addressNumeric = request.ContractAddress.ToUint160(this.network);

    ContractTxData txData;
    if (request.Parameters != null && request.Parameters.Any())
    {
        try
        {
            object[] methodParameters = this.methodParameterStringSerializer.Deserialize(request.Parameters);
            txData = new ContractTxData(ReflectionVirtualMachine.VmVersion, (Stratis.SmartContracts.RuntimeObserver.Gas)request.GasPrice, (Stratis.SmartContracts.RuntimeObserver.Gas)request.GasLimit, addressNumeric, request.MethodName, methodParameters);
         ...

This method first checks the balance of the sender to see if they have sufficient funds to call the smart contract. Then there is a call to create a ContractTxData for storing the details of the smart contract transaction. If we look at the ContractTxData class we can see some of the data that is required for smart contract execution:

public ContractTxData(int vmVersion, ulong gasPrice, RuntimeObserver.Gas gasLimit, uint160 contractAddress,
            string method, object[] methodParameters = null, string[] signatures = null)
        {
            this.OpCodeType = (byte) ScOpcodeType.OP_CALLCONTRACT;
            this.VmVersion = vmVersion;
            this.GasPrice = gasPrice;
            this.GasLimit = gasLimit;
            this.ContractAddress = contractAddress;
            this.MethodName = method;
            this.MethodParameters = methodParameters;
            this.ContractExecutionCode = new byte[0];
            this.Signatures = signatures;
        }

Stratis Node Smart Contract Execution Configuration

The Stratis node API follows the typical configuration pattern of being created in a fluent style with required services injected into controllers via dependency injection. We can see all the classes used for smart contract execution like ReflectionVirtualMachine injected as singletons into the IServiceCollection services set of the node in the AddSmartContracts method:

/// <summary>
/// Adds the smart contract feature to the node.
/// </summary>
public static IFullNodeBuilder AddSmartContracts(this IFullNodeBuilder fullNodeBuilder, Action<SmartContractOptions> options = null, Action<SmartContractOptions> preOptions = null)
{                                                                            LoggingConfiguration.RegisterFeatureNamespace<SmartContractFeature>("smartcontracts");
fullNodeBuilder.ConfigureFeature(features =>
{
    features
        .AddFeature<SmartContractFeature>()
        .FeatureServices(services =>
         {
            ...
            // CONTRACT EXECUTION ----------------------------------------
            services.AddSingleton<IInternalExecutorFactory, InternalExecutorFactory>();
            services.AddSingleton<IContractAssemblyCache, ContractAssemblyCache>();
            services.AddSingleton<IVirtualMachine, ReflectionVirtualMachine>();
...
            services.AddSingleton<ILocalExecutor, LocalExecutor>();

When a Stratis node runs as a Cirrus sidechain node (the sidechain where smart contracts execute), it configures itself to use the above feature defined by the AddSmartContracts method when starting up from Program.cs.

            IFullNodeBuilder nodeBuilder = new FullNodeBuilder()
            .UseNodeSettings(nodeSettings, dbType)
            .UseBlockStore(dbType)
            .UseMempool()
            .AddSmartContracts(options =>
            {
                options.UseReflectionExecutor();
                options.UsePoAWhitelistedContracts();
            })

The UseReflectionExecutor method is used to enable adding essential services like the SmartContractValidator service.

public static SmartContractOptions UseReflectionExecutor(this SmartContractOptions options)
{
    IServiceCollection services = options.Services;

    // Validator
    services.AddSingleton<ISmartContractValidator, SmartContractValidator>();
    // Executor et al.
    services.AddSingleton<IContractRefundProcessor, ContractRefundProcessor>();
    services.AddSingleton<IContractTransferProcessor, ContractTransferProcessor>();
   services.AddSingleton<IKeyEncodingStrategy, BasicKeyEncodingStrategy>();
   services.AddSingleton<IContractExecutorFactory, ReflectionExecutorFactory>();
   ...

The ReflectionExecutorFactory class sets up the needed objects for executing a smart contract method, like objects for logging, serialization, modifying the blockchain state, performing cryptocurrency transfers, handling refunds in the event execution fails, and so on:

public ReflectionExecutorFactory(ILoggerFactory loggerFactory,
            ICallDataSerializer serializer,
            IContractRefundProcessor refundProcessor,
            IContractTransferProcessor transferProcessor,
            IStateFactory stateFactory,
            IStateProcessor stateProcessor,
            IContractPrimitiveSerializer contractPrimitiveSerializer)
        {
            this.loggerFactory = loggerFactory;
            this.refundProcessor = refundProcessor;
...

The CreateExecutor method of ReflectionExecutorFactory returns a ContractExecutor object which actually kicks off the process of executing a smart contract method:

public IContractExecutor CreateExecutor(
            IStateRepositoryRoot stateRepository,
            IContractTransactionContext transactionContext)
{
            return new ContractExecutor(this.serializer, stateRepository, this.refundProcessor, this.transferProcessor, this.stateFactory, this.stateProcessor, this.contractPrimitiveSerializer);
}

The ContractExecutor class contains the Execute method:

public IContractExecutionResult Execute(IContractTransactionContext transactionContext)
{
    // Deserialization can't fail because this has already been through SmartContractFormatRule.
    Result<ContractTxData> callDataDeserializationResult =   this.serializer.Deserialize(transactionContext.Data);
    ContractTxData callData = callDataDeserializationResult.Value;
    bool creation = callData.IsCreateContract;

    var block = new Block(
        transactionContext.BlockHeight,
        transactionContext.CoinbaseAddress.ToAddress()
    );

    IState state = this.stateFactory.Create(
                this.stateRoot,
                block,
                transactionContext.TxOutValue,
                transactionContext.TransactionHash);

    StateTransitionResult result;
    IState newState = state.Snapshot();

    if (creation)
            {
                var message = new ExternalCreateMessage(
                    transactionContext.Sender,
                    transactionContext.TxOutValue,
                    callData.GasLimit,
                    callData.ContractExecutionCode,
                    callData.MethodParameters
                );

                result = this.stateProcessor.Apply(newState, message);
            }
            else
            {
                var message = new ExternalCallMessage(
                        callData.ContractAddress,
                        transactionContext.Sender,
                        transactionContext.TxOutValue,
                        callData.GasLimit,
                        new MethodCall(callData.MethodName, callData.MethodParameters)
                );

                result = this.stateProcessor.Apply(newState, message);
            }

We can look at this Execute()method in detail as this method contains the guts of executing a .NET smart contract.

.NET Smart Contract Execution

First, we deserialize the data passed in to get the ContractTxData instance we saw before that is created for smart contract execution. Then we create a new Block which will contain the new transactions that execution of the smart contract will add to the blockchain. Remember state changes caused by execution of smart contract code can only add blocks to the blockchain, not modify existing ones. We create a snapshot of the current blockchain state that the smart contract method will execute on. The next step in the Execute method depends on if we are creating a smart contract or just executing an existing smart contract method...we'll only consider this latter case here. The code creates an ExternalCallMessage with all the data for the smart contract method call, and then calls the Apply() method of the StateProcessor object. Executing an existing smart contract method means the blockchain must transition to a new state including any transactions and transfers created, so it is in this Apply() method that we can finally see where the .NET code will be executed.

/// <summary>
        /// Applies an externally generated contract method call message to the current state.
        /// </summary>
        public StateTransitionResult Apply(IState state, ExternalCallMessage message)
        {
            var gasMeter = new GasMeter(message.GasLimit);
            gasMeter.Spend((Gas)GasPriceList.BaseCost);

            var observer = new Observer(gasMeter, new MemoryMeter(ReflectionVirtualMachine.MemoryUnitLimit));
            var executionContext = new ExecutionContext(observer);

            byte[] contractCode = state.ContractState.GetCode(message.To);

            if (contractCode == null || contractCode.Length == 0)
            {
                return StateTransitionResult.Fail(gasMeter.GasConsumed, StateTransitionErrorKind.NoCode);
            }

            // For external calls we need to increment the balance state to take into
            // account any funds sent as part of the original contract invocation transaction.
            state.AddInitialTransfer(new TransferInfo(message.From, message.To, message.Amount));

            return this.ApplyCall(state, message, contractCode, executionContext);
        }

First, the method ensures the sender's balance is enough to make the smart contract call. Then it creates meters for tracking gas and memory usage (and deducts the fixed, base cost of calling a smart contract method from the meter.) Finally, in the ApplyCall() method we see the use of the ReflectionVirtualMachine:

private StateTransitionResult ApplyCall(IState state, CallMessage message, byte[] contractCode, ExecutionContext executionContext)
        {
            // This needs to happen after the base fee is charged, which is why it's in here.

            if (message.Method.Name == null)
            {
                return StateTransitionResult.Fail(executionContext.GasMeter.GasConsumed, StateTransitionErrorKind.NoMethodName);
            }

            string type = state.ContractState.GetContractType(message.To);

            ISmartContractState smartContractState = state.CreateSmartContractState(state, executionContext.GasMeter, message.To, message, state.ContractState);

            VmExecutionResult result = this.Vm.ExecuteMethod(smartContractState, executionContext, message.Method, contractCode, type);

            bool revert = !result.IsSuccess;

            if (revert)
            {
                return StateTransitionResult.Fail(
                    executionContext.GasMeter.GasConsumed,
                    result.Error);
            }

            return StateTransitionResult.Ok(
                executionContext.GasMeter.GasConsumed,
                message.To,
                result.Success.Result
            );
        }

TheExecuteMethod() method of ReflectionVirtualMachinecontains the guts of executing the .NET CIL code of a smart contract on a snapshot of blockchain state stored in memory. This method contains code like:

var rewriter = new ObserverInstanceRewriter();
                    if (!this.Rewrite(moduleDefinition, rewriter))
                        return VmExecutionResult.Fail(VmExecutionErrorKind.RewriteFailed, "Rewrite module failed");

In the previous article, we saw how smart contract CIL code is rewritten to do things like track gas and memory usage, using implementations of IObserverMethodRewriter. The job of the ObserverInstanceRewriter class here is to rewrite the smart contract method with gas and memory tracking rewriters that will add code that references the gas and memory meter observers that were placed in the smart contract execution context by the Apply() method. The ObserverInstanceRewriter class has this static member:

 private static readonly List<IObserverMethodRewriter> methodRewriters = new List<IObserverMethodRewriter>
        {
            new GasInjectorRewriter(),
            new MemoryLimitRewriter()
        };

which contains the CIL rewriters that will be used. After rewriting the method to inject the gas and memory observers the ReflectionVirtualMachine executes the smart contract method using the native .NET reflection capability by calling the contract's Invoke() method:

 //         //Set new Observer and load and execute.
            assemblyPackage.Assembly.SetObserver(executionContext.Observer);

            IContractInvocationResult invocationResult = contract.Invoke(methodCall);

The contract's Invoke() method calls InvokeInternal() which just calls the .NET MethodBase type's Invoke() method to actually execute the method's CIL code on the parameters passed to it via the call to the node API and the blockchain state snapshot created to hold any new transaction and transfer data created by the smart contract call. These state changes will be actually applied to the blockchain in a transactional process in case rollback to the previous state is required:

Conclusion

This article looked at some of the details of how the Stratis smart contract platform executes .NET smart contracts through a Cirrus sidechain node's API, using the .NET CLR as the smart contract VM. The end-to-end use of .NET by the Stratis smart contract platform means many capabilities of the .NET platform like executing CIL code via the reflection APIs can be used without having to develop this capability from scratch like in a bespoke VM, and development of the Stratis smart contract platform can focus on adding higher-level capabilities instead. Future articles will look at new tools being developed to support .NET smart contract development on the Stratis platform.