Precompiles
Precompiles are predefined smart contracts with unique addresses that provide specific functionality. Instead of being executed at the EVM bytecode level, they are executed natively by the Kakarot client in Cairo. The main purpose of precompiles is to introduce functions that would be computationally expensive if executed in EVM bytecode, as well as functions that facilitate interaction between Layer 1 (L1) and Layer 2 (L2). By having these functions natively integrated into the Kakarot client, they can be optimized for better performance.
Kakarot supports some of the precompiles available in Ethereum, and also provides L2-specific precompiles. These L2-specific precompiles have methods that smart contracts can call in a way similar to calling Solidity functions. One of these precompiles is the Cairo Precompile.
Cairo Precompile
Kakarot ZK-EVM being a Starknet appchain, it is possible to run Cairo Contracts
on Kakarot. The address 75001
is reserved for the "Cairo precompile", that
lets you invoke Cairo contracts from EVM contracts. To interact with the Cairo
precompile, you can use the solidity
CairoLib
from your solidity contracts.
Calling a Cairo contract from an EVM contract is a restricted operation that requires the caller contract to be whitelisted. This measure is in place to prevent attempts to call a Cairo contract whose execution would panic and be un-provable. To whitelist a contract, please contact the Kakarot team.
The CairoLib library contains methods to call a Cairo contract or class, either
via call
or staticcall
.
Note: The behavior of high-level
calls
in solidity prevents calling precompiles directly. As such, the library uses the low-levelcall
andstaticcall
opcodes to interact with these Cairo precompiles.
// SPDX-License-Identifier: MIT
pragma solidity >=0.7.0 <0.9.0;
library CairoLib {
/// @dev The Cairo precompile contract's address.
address constant CAIRO_PRECOMPILE_ADDRESS = 0x0000000000000000000000000000000000075001;
/// @notice Performs a low-level call to a Cairo contract deployed on the Starknet appchain.
/// @dev Used with intent to modify the state of the Cairo contract.
/// @param contractAddress The address of the Cairo contract.
/// @param functionSelector The function selector of the Cairo contract function to be called.
/// @param data The input data for the Cairo contract function.
/// @return returnData The return data from the Cairo contract function.
function callContract(
uint256 contractAddress,
uint256 functionSelector,
uint256[] memory data
) internal returns (bytes memory returnData);
/// @notice Performs a low-level call to a Cairo contract deployed on the Starknet appchain.
/// @dev Used with intent to read the state of the Cairo contract.
/// @param contractAddress The address of the Cairo contract.
/// @param functionSelector The function selector of the Cairo contract function to be called.
/// @param data The input data for the Cairo contract function.
/// @return returnData The return data from the Cairo contract function.
function staticcallContract(
uint256 contractAddress,
uint256 functionSelector,
uint256[] memory data
) internal view returns (bytes memory returnData);
}
It contains two functions, callContract
and staticcallContract
, that allow
the user to call a Cairo contract or class deployed on the Starknet appchain.
The method takes three arguments:
contractAddress
: The address of the Cairo contract to callfunctionSelector
: The selector of the function to call, as thesn_keccak
of the entrypoint name:keccak("entrypoint_name") % 2^250
data
: The calldata to pass to the Cairo contract, as individual bytes
Contract developers can use this library to interact with the Cairo precompiles. Let's take an example of a contract that calls a Cairo contract to increment a counter:
#[starknet::contract]
pub mod Counter {
#[storage]
struct Storage{
counter: u256
}
#[external(v0)]
pub fn inc(ref self: ContractState) {
self.counter.write(self.counter.read() + 1);
}
#[external(v0)]
pub fn set_counter(ref self: ContractState, new_counter: u256) {
self.counter.write(new_counter);
}
#[external(v0)]
pub fn get(self: @ContractState) -> u256 {
self.counter.read()
}
}
Calling this contract from an EVM contract would look like this:
// SPDX-License-Identifier: MIT
pragma solidity >=0.7.0 <0.9.0;
import "./CairoLib.sol";
contract CairoCounterCaller {
/// @dev The cairo contract to call
uint256 cairoCounter;
/// @dev The cairo function selector to call - `inc`
uint256 constant FUNCTION_SELECTOR_INC = uint256(keccak256("inc")) % 2**250;
/// @dev The cairo function selector to call - `set_counter`
uint256 constant FUNCTION_SELECTOR_SET_COUNTER = uint256(keccak256("set_counter")) % 2**250;
/// @dev The cairo function selector to call - `get`
uint256 constant FUNCTION_SELECTOR_GET = uint256(keccak256("get")) % 2**250;
constructor(uint256 cairoContractAddress) {
cairoCounter = cairoContractAddress;
}
function getCairoCounter() public view returns (uint256 counterValue) {
// `get_counter` takes no arguments, so data is empty
uint256[] memory data;
bytes memory returnData = CairoLib.staticcallContract(cairoCounter, FUNCTION_SELECTOR_GET, data);
// The return data is a 256-bit integer, so we can directly cast it to uint256
return abi.decode(returnData, (uint256));
}
/// @notice Calls the Cairo contract to increment its internal counter
function incrementCairoCounter() external {
// `inc` takes no arguments, so data is empty
uint256[] memory data;
CairoLib.callContract(cairoCounter, FUNCTION_SELECTOR_INC, data);
}
/// @notice Calls the Cairo contract to set its internal counter to an arbitrary value
/// @dev The counter value is split into two 128-bit values to match the Cairo contract's expected inputs (u256 is composed of two u128s)
/// @param newCounter The new counter value to set
function setCairoCounter(uint256 newCounter) external{
// The u256 input must be split into two u128 values to match the expected cairo input
uint128 newCounterLow = uint128(newCounter);
uint128 newCounterHigh = uint128(newCounter >> 128);
uint256[] memory data = new uint256[](2);
data[0] = uint256(newCounterLow);
data[1] = uint256(newCounterHigh);
CairoLib.callContract(cairoCounter, FUNCTION_SELECTOR_SET_COUNTER, data);
}
}
Once deployed, the contract can be called to increment the counter in a Cairo
contract deployed at starknet address cairoCounter
. The deployment address
will need to be communicated to Kakarot for the precompile to be whitelisted.
To execute the Cairo contract, we need to convert the EVM calldata, expressed as
a bytes
, to the expected Cairo calldata format Array<felt252>
. In Solidity,
the data
sent with the call will be represented as a uint256[]
, where each
uint256
element will be cast to a felt252
in Cairo. Therefore, each 256-bit
word sequence in the EVM calldata must correspond to an element of at most 251
bits, which is Cairo's native field element size. If the value being passed is
less than 251 bits, it can be directly cast to a felt252
in Cairo.
For example, consider the setCairoCounter
function mentioned above. If we want
to increase the counter by 1, the data
in Solidity would be:
uint256[] memory data = new uint256[](2);
data[0] = 0;
data[1] = 1;
In this case, the Cairo expected input is a u256
, which is composed of two
felt
values. Therefore, the newCounter
value is split into two values
smaller than the field element size, and the resulting data
array is of
size 2.
Similarly, the return data of the Cairo contract is deserialized into a
uint256[]
where each returned felt has been cast to a uint256.
Note: It is left to the responsibility of the wrapper contract developer to ensure that the calldata is correctly serialized to match the Cairo contract's expected inputs, and to properly deserialize the return data into the expected type.