Skip to main content

Adapting common design patterns to the async model

This guide discusses the common design patterns in smart contract development and shows how they can be adapted to the async programming model.

The design patterns in the guide include:

  • Checks Effects Interaction, a pattern for protecting a contract against re-entrancy attacks
  • Guard Check, an essential pattern for ensuring inputs and outputs match expectations and execution ensures atomicity
  • Clone Factory, a cost-efficient way to deploy and manage new smart contracts

Checks Effects Interaction

The Checks Effects Interaction pattern is designed to protect a contract against re-entrancy attacks. Here is its basic implementation on Ethereum:

mapping(address => uint) balances;

function withdraw(uint amount) public {
require(balances[msg.sender] >= amount);

balances[msg.sender] -= amount;

msg.sender.transfer(amount);
}

In the above snippet, the require() statement (the check) is first, followed by updating the balances mapping (the effect). The final statement sends funds to another contract (the interaction). An attacker is unable to re-enter inside the withdraw function: on re-entry, balances is already updated to reflect the new value, and the initial require() statement will always fail.

Async calls add one more consideration to the pattern:

mapping(address => uint) balances;

function badCheckEffectsInteraction(address dst, uint amount) public {
require(balances[msg.sender] >= amount);

balances[msg.sender] -= amount;

Nil.asyncCall(dst, address(this), amount, "");
}

The example implements the pattern by simply replacing the transfer with asyncCall(). This raises an issue: if the async call fails, the contract will not know that the balances mapping also has to be reverted to its original state.

The sendRequest() function provides a convenient way for implementing Check Effects Interaction correctly:

mapping(address => uint) exampleBalances;

function goodCheckEffectInteration(address dst, uint amount) public {
require(exampleBalances[msg.sender] >= amount);
exampleBalances[msg.sender] -= amount;

bytes memory context = abi.encodeWithSelector(
this.callback.selector,
amount
);
Nil.sendRequest(dst, amount, Nil.ASYNC_REQUEST_MIN_GAS, context, "");
}

function callback(
bool success,
bytes memory returnData,
bytes memory context
) public payable onlyResponse {
uint amount = abi.decode(context, (uint));
if (!success) {
exampleBalances[msg.sender] += amount;
}
}

The callback function specified inside context is always executed once the async call is processed regardless of its results. This makes the callback the ideal place for reverting any changes to the contract state. In the example, the exampleBalances mapping is reverted to its pre-request state to reflect that the transfer was unsuccessful.

Guard Check

The Guard Check pattern helps achieve these goals:

  • Only execute certain logic if all necessary conditions are met
  • In case conditions are not met, revert all changes to the contract state

Typically, the Guard Check pattern is implemented by using the require() and assert() functions:

require(addr != address(0));

assert(addr != address(0));

require() is typically used for check for errors in function inputs. assert() is used for detecting bugs in the contract logic. assert() uses all gas sent with a message while require() can refund gas that has not been used at the time when an exception was thrown. This means that require() is typically used as early as possible while assert() checks are done at the end of a function.

Async programming introduces a complication to conducting such checks: there is no fool-proof method for ensuring an async call is successful.


pragma solidity ^0.8.9;

import "@nilfoundation/smart-contracts/contracts/Nil.sol";

contract GuardCheck {
uint256 successfulCallsCounter = 0;

function badGuardCheckExample(address dst, uint256 amount) public payable {
require(dst != address(0));
require(msg.value != 0);
require(msg.value > amount);
uint balanceBeforeAsyncCall = address(this).balance;
Nil.asyncCall(dst, address(this), amount, "");

assert(address(this).balance == balanceBeforeAsyncCall - amount);
successfulCallsCounter += 1;
}
}

In the above example, the assert() check may succeed only for asyncCall() to later encounter an error and the message value being refunded to the contract. However, since assert() has already succeeded, there is no way to reverse changes to the successfulCallsCounter property.

Delegating post-assert statements to a callback function resolves the issue:


contract GoodGuardCheck is NilBase {
uint256 successfulCallsCounter = 0;
address guardCheckerIntermediaryAddress;

constructor(address _guardCheckerIntermediaryAddress) {
guardCheckerIntermediaryAddress = _guardCheckerIntermediaryAddress;
}

function goodGuardCheckExample(address dst, uint256 amount) public payable {
require(dst != address(0));
require(msg.value != 0);
require(msg.value > amount);
uint balanceBeforeAsyncCall = address(this).balance;
bytes memory context = abi.encodeWithSelector(this.callback.selector);
bytes memory callData = abi.encodeWithSignature("receive()");
Nil.sendRequest(
dst,
Nil.ASYNC_REQUEST_MIN_GAS,
amount,
context,
callData
);
assert(address(this).balance == balanceBeforeAsyncCall - amount);
}

function callback(
bool success,
bytes memory returnData,
bytes memory context
) public onlyResponse {
require(success, "Transfer failed!");
successfulCallsCounter += 1;
}
}

In the snippet, successfulCallsCounter is only increased after the contract receives a response to the transfer and only if the transfer was successful. The callback function can be provided with its own require() statement which propagates the pattern.

Clone Factory

The basic Factory pattern involves deploying a 'master' smart contract and then deploying and managing several 'child' contracts via this 'master'. The Clone Factory is a cost-efficient variation of this pattern:

  • A 'special' child contract is deployed first
  • The factory contract is deployed and starts deploying child contracts
  • All child must delegate calls to them to the initial 'special' child

A basic implementation of Clone Factory could look as follows when using the @optionality.io/clone-factory package:

import '@optionality.io/clone-factory/contracts/CloneFactory.sol';

contract Factory is CloneFactory {
Child[] public children;
address masterContract;

constructor(address _masterContract){
masterContract = _masterContract;
}

function createChild(uint data) external{
Child child = Child(createClone(masterContract));
child.init(data);
children.push(child);
}

function getChildren() external view returns(Child[] memory){
return children;
}
}

contract Child{
uint public data;

function init(uint _data) external {
data = _data;
}
}

On =nil;, such an approach would only work within the confines of one execution shard. The createClone function uses delegateCall which cannot handle cross-shard messages. The pattern can be re-implemented as follows:

pragma solidity ^0.8.9;

import "@nilfoundation/smart-contracts/contracts/Nil.sol";

contract MasterChild {
uint256 private value;

event ValueChanged(uint256 newValue);

receive() external payable {}

function increment() public {
value += 1;
emit ValueChanged(value);
}

function getValue() public view returns (uint256) {
return value;
}
}

contract CloneFactory {
address public masterChildAddress;

constructor(address _masterChildAddress) {
masterChildAddress = _masterChildAddress;
}

function createCloneBytecode(
address target
) internal returns (bytes memory finalBytecode) {
bytes memory code = new bytes(55);
bytes20 targetBytes = bytes20(target);
assembly {
let codePtr := add(code, 32)
mstore(
codePtr,
0x3d602d80600a3d3981f3363d3d373d3d3d363d73000000000000000000000000
)
mstore(add(codePtr, 0x14), targetBytes)
mstore(
add(codePtr, 0x28),
0x5af43d82803e903d91602b57fd5bf30000000000000000000000000000000000
)
}
finalBytecode = code;
}

function createCounterClone(uint256 salt) public returns (address) {
bytes memory cloneBytecode = createCloneBytecode(masterChildAddress);
uint shardId = Nil.getShardId(masterChildAddress);
uint shardIdFactory = Nil.getShardId(address(this));
require(
shardId == shardIdFactory,
"factory and child are not on the same shard!"
);
address result = Nil.asyncDeploy(
shardId,
address(this),
address(this),
0,
Nil.FORWARD_REMAINING,
0,
cloneBytecode,
salt
);

return result;
}
}

contract FactoryManager {
mapping(uint => address) public factories;
mapping(uint => address) public masterChildren;
bytes private code = type(CloneFactory).creationCode;

function deployNewMasterChild(uint shardId, uint256 salt) public {
address result = Nil.asyncDeploy(
shardId,
address(this),
address(this),
0,
Nil.FORWARD_REMAINING,
0,
type(MasterChild).creationCode,
salt
);

masterChildren[shardId] = result;
}

function deployNewFactory(uint shardId, uint256 salt) public {
require(factories[shardId] == address(0), "factory already exists!");
bytes memory data = bytes.concat(
type(CloneFactory).creationCode,
abi.encode(masterChildren[shardId])
);
address result = Nil.asyncDeploy(
shardId,
address(this),
address(this),
0,
Nil.FORWARD_REMAINING,
0,
data,
salt
);

factories[shardId] = result;
}
}

The MasterChild is a simple 'counter' contract. The CloneFactory contract re-implements the createClone function as createCloneBytecode. It is functionally identical to the createClone function from @optionality.io/clone-factory except for the fact that it does not deploy a contract. Instead, it returns the deployment bytecode which is later passed to Nil.asyncDeploy() in the createCounterClone function.

The biggest change to the original pattern is the addition of the FactoryManager contract. Because delegateClone (whose bytecode is wrapped around the contract address in createCloneBytecode) does not support sharding, CloneFactory, MasterChild, and any 'proxy' clones created by the factory have to be deployed on the same shard. FactoryManager gets around that by allowing for deploying new factories and children on different shards:

  • FactoryManager is deployed first on any shard
  • A MasterChild is deployed on any shard via deployNewMasterChild()
  • A CloneFactory is deployed on the same shard as the MasterChild
  • Proxy clones are created by calling createCounterClone inside the CloneFactory
  • There is only one CloneFactory / MasterChild pair per shard
Additional improvements

This implementation can be made more secure by restricting functions insde FactoryManager to the contract owner.