Write a Smart Contract

To start our contract, we need to import the interface to our HybridAccount contract. The address of this HybridAccount is passed to the constructor and is stored as an immutable variable. Next we define other contract variables and helper functions.

The "counters" mapping provides a unique numeric counter scoped to each user who calls the contract.

// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.12;

import "../samples/HybridAccount.sol";

contract TestCounter {
    mapping(address => uint256) public counters;

    address payable immutable hcAccount;  // NOTE - this variale is named "demoAddr" in older instances of the contract

    constructor(address payable _hcAccount) {
        hcAccount = _hcAccount;
    }
}

To implement our function to waste gas, we can leverage a for loop to increase transaction time:

  //helper method to waste gas
  // repeat - waste gas on writing storage in a loop
  // junk - dynamic buffer to stress the function size.
  mapping(uint256 => uint256) public xxx;
  uint256 public offset;

  function gasWaster(uint256 repeat, string calldata /*junk*/) external {
      for (uint256 i = 1; i <= repeat; i++) {
          offset++;
          xxx[offset] = i;
      }
  }

Now we can add the count() method. We initialize the HybridAccount with the hcAccount created prior, and allow for parameters a and b, our numbers to add and subtract together. We define x and y, do a quick check for b == 0, and encode our function offchain_addsub2(). We registered HybridAccount in the previous section to provide access to the offchain_addsub2() function on our off-chain function.

    function count(uint32 a, uint32 b) public {
        HybridAccount HA = HybridAccount(hcAccount);
        uint256 x;
        uint256 y;
        if (b == 0) {
            counters[msg.sender] = counters[msg.sender] + a;
            return;
        }

        bytes memory req = abi.encodeWithSignature("offchain_addsub2(uint32,uint32)", a, b);
        bytes32 userKey = bytes32(abi.encode(msg.sender));
        (uint32 error, bytes memory ret) = HA.CallOffchain(userKey, req);

The truly unique functionality of Hybrid Compute comes from HA.CallOffChain(userkey, req); this call will (assuming no error) return us the two numbers a and b after making computations off-chain The userKey parameter we pass in, generated by encoding msg.sender, distinguishes requests so that they may be processed concurrently without interefering with each other. As already mentioned in the previous section, our off-chain server maps the request made by the bundler via the hashed representation of our function-signature.

If we encounter an error during the CallOffChain() call, we either revert or set the counter forward by varying amounts. We can add these different cases in a series of else if blocks:

    function count(uint32 a, uint32 b) public {
        HybridAccount HA = HybridAccount(hcAccount);
        uint256 x;
        uint256 y;
        if (b == 0) {
            counters[msg.sender] = counters[msg.sender] + a;
            return;
        }

        bytes memory req = abi.encodeWithSignature("offchain_addsub2(uint32,uint32)", a, b);
        bytes32 userKey = bytes32(abi.encode(msg.sender));
        (uint32 error, bytes memory ret) = HA.CallOffchain(userKey, req);

        if (error == 0) {
            (x, y) = abi.decode(ret, (uint256, uint256)); // x=(a+b), y=(a-b)
            this.gasWaster(x, "abcd1234");
            counters[msg.sender] = counters[msg.sender] + y;
        } else if (b >= 10) {
            revert(string(ret));
        } else if (error == 1) {
            counters[msg.sender] = counters[msg.sender] + 100;
        } else {
            //revert(string(ret));
            counters[msg.sender] = counters[msg.sender] + 1000;
        }
    }

Notably, in the first if block, we decode the returned object ret into two uint256 values as the off-chain function returns two integers. The variables x and y hold the results of the addition and subtraction, respectively.

Calling Off-Chain

Within the HybridAccount contract itself, the CallOffchain() method calls through to another system contract named HCHelper. This is the source code of the function:

function CallOffchain(bytes32 userKey, bytes memory req) public returns (uint32, bytes memory) {
   require(PermittedCallers[msg.sender], "Permission denied");
   IHCHelper HC = IHCHelper(_helperAddr);
   userKey = keccak256(abi.encodePacked(userKey, msg.sender));
   return HC.TryCallOffchain(userKey, req);
}

In this example, the HybridAccount implements a simple whitelist of contracts which are allowed to call its methods. It would also be possible for a HybridAccount to implement additional logic here, such as requiring payment of an ERC20 token to perform an off-chain call. Conversely, the owner of a HybridAccount could choose to make the CallOffchain() method available to all callers without restriction.

You could take the optional opportunity for a HybridAccount contract to implement a billing system here, requiring a payment of ERC20 tokens or some other mechanism of collecting payment from the calling contract.

HybridAccount / HCHelper Interaction

Our HCHelper contract acts as a system-wide interface to the Hybrid Compute framework. Let's examine some of the calls we made to it in our own contract.

First, the helper checks an internal mapping to see if a response exists for the given request. If not, the method reverts with a special prefix, followed by an encoded version of the request parameters.

function TryCallOffchain(bytes32 userKey, bytes memory req) public returns (uint32, bytes memory) {
    bool found;
    uint32 result;
    bytes memory ret;

    bytes32 subKey = keccak256(abi.encodePacked(userKey, req));
    bytes32 mapKey = keccak256(abi.encodePacked(msg.sender, subKey));

    (found, success, ret) = getEntry(mapKey);

    if (found) {
        return (result, ret);
    } else {
        // If no off-chain response, check for a system error response.
        bytes32 errKey = keccak256(abi.encodePacked(address(this), subKey));

        (found, result, ret) = getEntry(errKey);
        if (found) {
            require(result != HC_ERR_NONE, "Invalid error code");
            return (result, ret);
        } else {
            // Nothing found, so trigger a new request.
            bytes memory prefix = "_HC_TRIG";
            bytes memory r2 = bytes.concat(prefix, abi.encodePacked(msg.sender, userKey, req));
            assembly {
                revert(add(r2, 32), mload(r2))
            }
        }
    }
}

If a response does exist, it's removed from the internal mapping and is returned to the caller. The map key encodes the request parameters, so that a response initiated by one request will not be returned later in response to a modified request from the caller.

To populate the response mapping, HybridAccount contracts use another method in the Helper:

function PutResponse(bytes32 subKey, bytes calldata response) public {
    //require(msg.sender == address(this)); // _requireFromEntryPointOrOwner();
    require(RegisteredCallers[msg.sender].owner != address(0), "Unregistered caller");
    //require(ResponseCache[mapKey].length == 0, "Cache entry already exists");

    require(response.length >= 32, "Response too short");
    bytes32 mapKey = keccak256(abi.encodePacked(msg.sender, subKey));
    ResponseCache[mapKey] = response;
}

Note that the msg.sender is included in the calculation of the internal map key, ensuring that only HybridAccount is able to populate the response (which it will later receive back in the TryCallOffchain() call). However, in the case of an error result of (success == false), there's also a provision for the HC implementation to insert a result under a different map key.

Account Abstraction calls PutResponse() and the off-chain userOperation must carry a valid signature in order to execute the operation.

That finishes our smart contract! Proceed to the next section to learn how to deploy it.

Last updated