SparqNet
Search
⌃K

Creating a Dynamic Contract (Advanced)

(This example uses an already existing contract within the project, the ERC20Wrapper contract, this is due to the fact that ERC20Wrapper is a very simple contract, while it shows differences between Solidity and OrbiterSDK contracts when calling other contracts)
Let's create a simple Dynamic Contract that can be used for depositing and withdrawing ERC20 tokens (ERC20Wrapper).
Take the following Solidity source code as an example:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract ERC20Wrapper {
mapping (address => mapping (address => uint256)) private _tokensAndBalances;
function getContractBalance(address token) public view returns(uint256) {
IERC20 erc20 = IERC20(token);
return erc20.balanceOf(address(this));
}
function getUserBalance(address token, address user) public view returns (uint256) {
return _tokensAndBalances[token][user];
}
function withdraw(address token, uint256 value) public {
require(_tokensAndBalances[token][msg.sender] >= value, "User doesn't have enough balance");
IERC20 erc20 = IERC20(token);
_tokensAndBalances[token][msg.sender] -= value;
erc20.transfer(msg.sender, value);
}
function transferTo(address token, address to, uint256 value) public {
require(_tokensAndBalances[token][msg.sender] >= value, "User doesn't have enough balance");
IERC20 erc20 = IERC20(token);
_tokensAndBalances[token][msg.sender] -= value;
erc20.transfer(to, value);
}
function deposit(address token, uint256 value) public {
IERC20 erc20 = IERC20(token);
erc20.transferFrom(msg.sender, address(this), value);
_tokensAndBalances[token][msg.sender] += value;
}
}

Step 1 - Creating the contract header

In order to recreate this contract, first we need to create the header (.h) and source (.cpp) files for it. Thus, we have the following header (see src/contract/erc20wrapper.h for the full header and includes):
class ERC20Wrapper : public DynamicContract {
private:
// ERC20 Address => UserAddress/UserBalance
// mapping(address => mapping(address => uint256)) internal _tokensAndBalances;
SafeUnorderedMap<Address,std::unordered_map<Address, uint256_t, SafeHash>> _tokensAndBalances;
// Override this to register your contract's own functions.
void registerContractFunctions() override;
public:
// Default constructor for loading the contract from the database.
ERC20Wrapper(
ContractManager::ContractManagerInterface &interface,
const Address& contractAddress,
const std::unique_ptr<DB> &db
);
// Default constructor for building a new contract.
ERC20Wrapper(
ContractManager::ContractManagerInterface &interface,
const Address& address,
const Address& creator,
const uint64_t& chainId,
const std::unique_ptr<DB> &db
);
// Destructor. Override this to save all your state variables.
~ERC20Wrapper() override;
// function getContractBalance(address _token) public view returns (uint256) { return _tokensAndBalances[_token][address(this)]; }
std::string getContractBalance(const Address& token) const;
// function getUserBalance(address _token, address _user) public view returns (uint256) { return _tokensAndBalances[_token][_user]; }
std::string getUserBalance(const Address& token, const Address& user) const;
// function withdraw (address _token, uint256 _value) public returns (bool)
void withdraw(const Address& token, const uint256_t& value);
// function transferTo(address _token, address _to, uint256 _value) public returns (bool)
void transferTo(const Address& token, const Address& to, const uint256_t& value);
// function deposit(address _token, uint256 _value) public returns (bool)
void deposit(const Address& token, const uint256_t& value);
};
Here, we recreated the contract's functions but also added a few extra functions (explained on the previous sections). In short:
  • We create two constructors
  • We override the registerContractFunctions() function
  • We override the destructor
  • We use private SafeVariables (e.g. SafeUnorderedMap) to handle the contract's actual variables
Now, let's look at the definition of the contract itself.

Step 2 - Creating the contract constructor(s)

On the previous example, we have two constructors, presented as following:
ERC20Wrapper(
ContractManager::ContractManagerInterface &interface,
const Address& contractAddress,
const std::unique_ptr<DB> &db
);
ERC20Wrapper(
ContractManager::ContractManagerInterface &interface,
const Address& address,
const Address& creator,
const uint64_t& chainId,
const std::unique_ptr<DB> &db
);
The first one is being used to load a contract from the database during ContractManager construction (information about this will be presented on the next section).
The second one is being used to create a new contract during a contract creating call on ContractManager.
Let's check their respective declarations on the source file:
// Load the contract directly from the database
ERC20Wrapper::ERC20Wrapper(
ContractManager::ContractManagerInterface &interface,
const Address& contractAddress,
const std::unique_ptr<DB> &db
) : DynamicContract(interface, contractAddress, db), _tokensAndBalances(this) {
registerContractFunctions();
auto tokensAndBalances = this->db->getBatch(
DBPrefix::contracts + this->getContractAddress().get() + "_tokensAndBalances"
);
for (const auto& dbEntry : tokensAndBalances) {
this->_tokensAndBalances[Address(dbEntry.key, true)][Address(dbEntry.value.substr(0, 20), true)] = Utils::fromBigEndian<uint256_t>(dbEntry.value.substr(20));
}
updateState(true);
}
Here, the first constructor will load the contract variables (address, name, etc.) from the database by itself, but you are required to load the variables of your contract on your own from there as well.
Use DBPrefix::contract + this->getContractAddress() as the prefix for the database key.
This constructor is automatically called by ContractManager constructor while loading the blockchain.
// Create the contract on the spot
ERC20Wrapper::ERC20Wrapper(
ContractManager::ContractManagerInterface &interface,
const Address& address,
const Address& creator,
const uint64_t& chainId,
const std::unique_ptr<DB> &db
) : DynamicContract(interface, "ERC20Wrapper", address, creator, chainId, db), _tokensAndBalances(this) {
registerContractFunctions();
updateState(true);
}
The second constructor will create a new contract from scratch, as there is no previous existing contract to load. Thus, you are required to initialize all the variables of your contract (address, name, etc.) by hand, within the DynamicContract initializer list (notice that your contract's name - "ERC20Wrapper" - is the same as your contract's class name - ERC20Wrapper).
Both constructors have to call registerContractFunctions() at the start, as well as updateState(true) at the end. Those will be explained further.

Step 3 - Creating the contract destructor

The contract's destructor is responsible to save the current information within the contract back to the database. This is done by the following code:
ERC20Wrapper::~ERC20Wrapper() {
DBBatch tokensAndBalancesBatch;
for (auto it = _tokensAndBalances.cbegin(); it != _tokensAndBalances.cend(); it++) {
for (auto it2 = it->second.cbegin(); it2 != it->second.cend(); it2++) {
std::string key = it->first.get();
std::string value;
value += it2->first.get();
value += Utils::uintToBytes(it2->second);
tokensAndBalancesBatch.puts.emplace_back(DBEntry(key, value));
}
}
this->db->putBatch(tokensAndBalancesBatch, DBPrefix::contracts + this->getContractAddress().get() + "_tokensAndBalances");
return;
}
Keep in mind that, for the prefix of the database, we use DBPrefix::contracts + this->getContractAddress().get() + "_tokensAndBalances". This is the same prefix that we used on the constructor to load the contract variables from the database.

Step 4 - Creating the contract functions

This step is pretty straightforward - functions that are callable by a transaction should be non-const and return void, while view functions should be const and return a std::string with the ABI encoded result. Check the following examples:
// view function
std::string ERC20Wrapper::getContractBalance(const Address& token) const {
auto* ERC20Token = this->getContract<ERC20>(token);
return ERC20Token->balanceOf(this->getContractAddress());
}
// view function
std::string ERC20Wrapper::getUserBalance(const Address& token, const Address& user) const {
auto it = this->_tokensAndBalances.find(token);
if (it == this->_tokensAndBalances.end()) return ABI::Encoder({0}).getRaw();
auto itUser = it->second.find(user);
if (itUser == it->second.end()) return ABI::Encoder({0}).getRaw();
return ABI::Encoder({itUser->second}).getRaw();
}
// callable function
void ERC20Wrapper::withdraw(const Address& token, const uint256_t& value) {
auto it = this->_tokensAndBalances.find(token);
if (it == this->_tokensAndBalances.end()) throw std::runtime_error("Token not found");
auto itUser = it->second.find(this->getCaller());
if (itUser == it->second.end()) throw std::runtime_error("User not found");
if (itUser->second <= value) throw std::runtime_error("Not enough balance");
itUser->second -= value;
ABI::Encoder encoder({this->getCaller(), value}, "transfer(address,uint256)");
this->callContract(token, encoder);
}
// callable function
void ERC20Wrapper::transferTo(const Address& token, const Address& to, const uint256_t& value) {
auto it = this->_tokensAndBalances.find(token);
if (it == this->_tokensAndBalances.end()) throw std::runtime_error("Token not found");
auto itUser = it->second.find(this->getCaller());
if (itUser == it->second.end()) throw std::runtime_error("User not found");
if (itUser->second <= value) throw std::runtime_error("Not enough balance");
itUser->second -= value;
ABI::Encoder encoder({to, value}, "transfer(address,uint256)");
this->callContract(token, encoder);
}
// callable function
void ERC20Wrapper::deposit(const Address& token, const uint256_t& value) {
ABI::Encoder encoder({this->getCaller(), this->getContractAddress(), value}, "transferFrom(address,address,uint256)");
this->callContract(token, encoder);
this->_tokensAndBalances[token][this->getCaller()] += value;
}
You can see that all the view functions are const and return a std::string, while the functions callable by a transaction are void and non-const.

Step 4.5 - Calling functions from other contracts

In order to call another contract's view function, you can get a pointer to it using the getContract() function.
The getContract() function is automatically protected in the case of casting a wrong typed contract or calling an inexistent contract. Besides that, the function returns a std::string with the respective ABI encoded result:
std::string ERC20Wrapper::getContractBalance(const Address& token) const {
auto* ERC20Token = this->getContract<ERC20>(token);
return ERC20Token->balanceOf(this->getContractAddress());
}
In order to call a callable function, you need to use the callContract() function.
You must encode the ABI of the function you want to call - in this example, we are calling the transferFrom function from another ERC20 contract, which is encoded as transferFrom(address,address,uint256), and its parameters are the caller, the contract address and the value, respectively, encoded as {this->getCaller(), this->getContractAddress(), value}.
void ERC20Wrapper::deposit(const Address& token, const uint256_t& value) {
ABI::Encoder encoder({this->getCaller(), this->getContractAddress(), value}, "transferFrom(address,address,uint256)");
this->callContract(token, encoder);
this->_tokensAndBalances[token][this->getCaller()] += value;
}
As this contract is consuming the ERC20 balance of another contract, you first need to approve the contract to spend the tokens. This can be done in the same manner as Solidity.

Step 5 - Registering the contract's functions

We need to override registerContractFunction() with the proper functions for them to be registered. This is done by calling the following functions on the derived contract:
void registerFunction(const std::string& functor, std::function<void(const ethCallInfo& tx)> f);
void registerPayableFunction(const std::string& functor, std::function<void(const ethCallInfo& tx)> f);
void registerViewFunction(const std::string& functor, std::function<std::string(const ethCallInfo& str)> f);
Each function should be used for their effective purpose:
  • registerFunction() is used to register a function that is called by a transaction (callable)
  • registerPayableFunction() is used to register a function that is called by a transaction (callable) and is payable
  • registerViewFunction() is used to register a view/const function
The functor argument should be the function signature by Solidity standards.
The function argument should be a lambda function declared on the spot, responsible for parsing ethCallInfo and calling the proper function. ethCallInfo is a std::tuple with the following information:
Index
Description
Type
0
From (where the call is coming from)
Address
1
To (where the call is going to)
Address
2
GasLimit
uint256_t
3
GasPrice
uint256_t
4
Value
uint256_t
5
Data
std::string
You can access each information by using std::get<index>(ethCallInfo). data.substr(0,4) will be the function signature and the remaining data will be the ABI encoded parameters.
We also provide the ABI namespace, which contains an encoder and decoder which you can use to encode and/or decode Solidity's ABI strings in order to call a function. See the following example:
void ERC20Wrapper::registerContractFunctions() {
this->registerViewFunction(Hex::toBytes("0x43ab265f"), [this](const ethCallInfo &callInfo) {
std::vector<ABI::Types> types = { ABI::Types::address };
ABI::Decoder decoder(types, std::get<5>(callInfo).substr(4));
return this->getContractBalance(decoder.getData<Address>(0));
});
this->registerViewFunction(Hex::toBytes("0x6805d6ad"), [this](const ethCallInfo &callInfo) {
std::vector<ABI::Types> types = { ABI::Types::address, ABI::Types::address };
ABI::Decoder decoder(types, std::get<5>(callInfo).substr(4));
return this->getUserBalance(decoder.getData<Address>(0), decoder.getData<Address>(1));
});
this->registerFunction(Hex::toBytes("0xf3fef3a3"), [this](const ethCallInfo &callInfo) {
std::vector<ABI::Types> types = { ABI::Types::address, ABI::Types::uint256 };
ABI::Decoder decoder(types, std::get<5>(callInfo).substr(4));
this->withdraw(decoder.getData<Address>(0), decoder.getData<uint256_t>(1));
});
this->registerFunction(Hex::toBytes("0xa5f2a152"), [this](const ethCallInfo &callInfo) {
std::vector<ABI::Types> types = { ABI::Types::address, ABI::Types::address, ABI::Types::uint256 };
ABI::Decoder decoder(types, std::get<5>(callInfo).substr(4));
this->transferTo(decoder.getData<Address>(0), decoder.getData<Address>(1), decoder.getData<uint256_t>(2));
});
this->registerFunction(Hex::toBytes("0x47e7ef24"), [this](const ethCallInfo &callInfo) {
std::vector<ABI::Types> types = { ABI::Types::address, ABI::Types::uint256 };
ABI::Decoder decoder(types, std::get<5>(callInfo).substr(4));
this->deposit(decoder.getData<Address>(0), decoder.getData<uint256_t>(1));
});
return;
}
After calling registerContractFunctions(), all the functions registered will be available to be called by RPC, eth_call, or a transaction.

Step 6 - Changing the ContractManager

After defining and implementing your contract(s), you need to register it/them in the ContractManager class itself. In order to do this, two functions have to be added to it, and the ethCall() functions and its constructor have to be modified.
First, let's implement the new two functions, both of which should be private within ContractManager and take a const ethCallInfo& as an argument. These functions should be called createNewCONTRACTNAMEContract() and validateCreateNewCONTRACTNAMEContract(), respectively. Replace CONTRACTNAME with the name of your contract.
In our example, those functions would be aptly named as createNewERC20WrapperContract() and validateCreateNewERC20WrapperContract().
The first function should create a new instance of the contract, and the second function should verify if the call is valid. See the example in the header (src/contract/contractmanager.h):
class ContractManager : BaseContract {
private:
// ...previous source code...
// Create a new ERC20Wrapper Contract.
// function createNewERC20WrapperContract() public {}
void createNewERC20WrapperContract(const ethCallInfo& callInfo);
// Check if transaction can actually create a new ERC20 contract.
void validateCreateNewERC20WrapperContract(const ethCallInfo& callInfo) const;
// ... next source code...
};
And their respecitve definition in the source file (src/contract/contractmanager.cpp):
void ContractManager::createNewERC20WrapperContract(const ethCallInfo& callInfo) {
if (this->caller != this->getContractCreator()) throw std::runtime_error("Only contract creator can create new contracts");
// Check if desired contract address already exists
const auto derivedContractAddress = this->deriveContractAddress(callInfo);
if (this->contracts.contains(derivedContractAddress)) throw std::runtime_error("Contract already exists");
std::unique_lock lock(this->contractsMutex);
for (const auto& [protocolContractName, protocolContractAddress] : ProtocolContractAddresses) {
if (protocolContractAddress == derivedContractAddress) throw std::runtime_error("Contract already exists");
}
this->contracts.insert(std::make_pair(derivedContractAddress, std::make_unique<ERC20Wrapper>(
this->interface, derivedContractAddress, this->getCaller(), this->options->getChainID(), this->db
)));
}
void ContractManager::validateCreateNewERC20WrapperContract(const ethCallInfo& callInfo) const {
if (this->caller != this->getContractCreator()) throw std::runtime_error("Only contract creator can create new contracts");
// Check if desired contract address already exists
const auto derivedContractAddress = this->deriveContractAddress(callInfo);
if (this->contracts.contains(derivedContractAddress)) throw std::runtime_error("Contract already exists");
std::unique_lock lock(this->contractsMutex);
for (const auto& [protocolContractName, protocolContractAddress] : ProtocolContractAddresses) {
if (protocolContractAddress == derivedContractAddress) throw std::runtime_error("Contract already exists");
}
}
The first function (create) should create a new instance of the contract and add it to the ContractManager. The second function (validate) should only parse the ABI and verify if the parameters are correct,
On both functions, the first if block enforces that only the chain creator can create new contracts, the second if block checks if the contract already exists, and the third if block checks if the contract address is already used by a Protocol Contract, which is not registered in the ContractManager.
Don't forget to include the file where the contract is defined (e.g. #include "erc20wrapper.h")!
Now, let's modify the ethCall() functions - under ContractManager::ethCall(const ethCallInfo& callInfo) you need to parse the functor of the call and call the corresponding function. In our example, we need to add the following lines:
// function createNewERC20WrapperContract() public {}
if (functor == Hex::toBytes("0x97aa51a3")) {
this->createNewERC20WrapperContract(callInfo);
return;
}
// function validateCreateNewERC20WrapperContract() public {}
if (functor == Hex::toBytes("0x97aa51a3")) {
this->validateCreateNewERC20WrapperContract(callInfo);
return;
}
}
You would do one if block per function that you need to register, one after the other. You can register as many functions as you want, though your ethCall() function might get a little "bloated" if you have too many of them. After the changes, the function would look something like this:
void ContractManager::ethCall(const ethCallInfo& callInfo) {
std::string functor = std::get<5>(callInfo).substr(0, 4);
if (this->getCommit()) {
// function createNewERC20Contract(string memory name, string memory symbol, uint8 decimals, uint256 supply) public {}
if (functor == Hex::toBytes("0xb74e5ed5")) {
this->createNewERC20Contract(callInfo);
return;
}
// function createNewERC20WrapperContract() public {}
if (functor == Hex::toBytes("0x97aa51a3")) {
this->createNewERC20WrapperContract(callInfo);
return;
}
// function createNewERC20NativeWrapperContract(string memory name, string memory symbol, uint8 decimals) public {}
if (functor == Hex::toBytes("0x8b0b8c4c")) {
this->createNewERC20NativeWrapperContract(callInfo);
return;
}
} else {
if (functor == Hex::toBytes("0xb74e5ed5")) {
this->validateCreateNewERC20Contract(callInfo);
return;
}
if (functor == Hex::toBytes("0x97aa51a3")) {
this->validateCreateNewERC20WrapperContract(callInfo);
return;
}
if (functor == Hex::toBytes("0x8b0b8c4c")) {
this->validateCreateNewERC20NativeWrapperContract(callInfo);
return;
}
}
throw std::runtime_error("Invalid function call");
}

Step 7 - Compiling it all

After everything is linked together within the source code, you need to add your contract's source and header file into the right CMakeLists.txt file - that would be located under src/contract/CMakeLists.txt.
Open that file and search for those lines, then add yours:
set(CONTRACT_HEADERS
...
${CMAKE_SOURCE_DIR}/src/contract/erc20wrapper.h
...
)
set(CONTRACT_SOURCES
...
${CMAKE_SOURCE_DIR}/src/contract/erc20wrapper.cpp
...
)
Once this is done, you can now run scripts/AIO-setup.sh, setup a local network, deploy your contract using the chain owner private key and test your contract compatibility with your favorite frontend tool.
In order to deploy your contract, you must call the respective createNewCONTRACTNAMEContract() with a transaction to the ContractManager contract address. The function will create a new contract and return the address of the new contract, you can then use the address to interact with the contract.
It is also possible to create tests using the internal test framework (catch2) - see the links for more information: