Build a Scheduler contract
Schedulers (cron jobs) are a type of task that allows developers to run scripts, commands, or software at specified times in the future or in intervals. This is particularly useful for tasks that need to occur at regular intervals, such as updating data, executing transactions, or performing maintenance tasks.
Unlike traditional systems where the operating system's cron scheduler can directly manage such tasks, blockchain-based schedulers require a different approach due to the decentralized and deterministic nature of blockchains. Developers often use external services or design their smart contracts in a way that they can be triggered by an external call at the right time, effectively simulating the behavior of a scheduler. In the context of smart transactions, schedulers can be used to trigger contract functions automatically at predetermined times.
Getting Started
To begin development, run the following commands:
forge install
forge build
# Run test suite
forge test
To demonstrate our ability to create schedulers, we will create a simple contract that will increment a counter on a dummy smart contract every few blocks.
Deployments
First, we deploy the smart contracts:
-
Deploy the Laminator Contract: This contract is responsible for managing the transactions in the mempool. It creates instances of
LaminatedProxy
to handle individual transactions. -
Deploy the CallBreaker Contract: The
CallBreaker
contract acts as a security mechanism, ensuring that only valid calls are executed. -
Deploy the CronTwoCounter Contract: This contract is our main contract for the tutorial, which will have its counter incremented every few blocks. It requires the address of the
CallBreaker
contract for initialization. -
Compute the Proxy Address: Using the
Laminator
contract, we compute the proxy address for thepusher
, which is a necessary step before the actual deployment. -
Initialize the Contracts: Finally, we call the
deployerLand
function with the address of thepusher
. This function initializes the contracts and sets up the environment for our scheduler.
Creating the Scheduler
To automate the process of incrementing the counter on our CronTwoCounter
contract, we will implement a function in the user's domain. This function will orchestrate a series of calls to be executed by our scheduler. The steps involved are as follows:
-
Increment the Counter: The first call pushes an operation to increment the counter on the
CronTwoCounter
contract. This is the primary action of our scheduler. -
Tip the Solver: To incentivize the execution of our scheduler, we include a tip for the solver. This is facilitated by making a call to the
CallBreaker
contract with a specified tip amount. -
Check for Continuation: Before setting up the scheduler for recursion, we push a call to check if the conditions for continuation are met. This is done by calling the
shouldContinue
function on theCronTwoCounter
contract. -
Copy the Current Job for Recursion: If the continuation conditions are met, we then push a call to copy the current job. This is achieved by calling the
copyCurrentJob
function on thepusherLaminated
instance, passing the number of blocks after which the job should repeat and a pointer to the continuation check function. This setup enables tail recursion, allowing our scheduler to repeat at specified intervals.
Below is the Solidity code snippet that implements the userLand
function, encapsulating the steps outlined above:
function userLand() public returns (uint256) {
// send proxy some eth
pusherLaminated.transfer(1 ether);
// Userland operations
CallObject[] memory pusherCallObjs = new CallObject[](4);
pusherCallObjs[0] = CallObject({
amount: 0,
addr: address(counter),
gas: 10000000,
callvalue: abi.encodeWithSignature("increment()")
});
pusherCallObjs[1] = CallObject({amount: _tipWei, addr: address(callbreaker), gas: 10000000, callvalue: ""});
CallObject memory callObjectContinueFunctionPointer = CallObject({
amount: 0,
addr: address(counter),
gas: 10000000,
callvalue: abi.encodeWithSignature("shouldContinue()")
});
bytes memory callObjectContinueFnPtr = abi.encode(callObjectContinueFunctionPointer);
pusherCallObjs[2] = CallObject({
amount: 0,
addr: pusherLaminated,
gas: 10000000,
callvalue: abi.encodeWithSignature("copyCurrentJob(uint256,bytes)", _blocksInADay, callObjectContinueFnPtr)
});
pusherCallObjs[3] = CallObject({
amount: 0,
addr: address(counter),
gas: 10000000,
callvalue: abi.encodeWithSignature("frontrunBlocker()")
});
return laminator.pushToProxy(abi.encode(pusherCallObjs), 1);
}
The solver's ability to pull twice, once for each of the jobs that the scheduler has duplicated, is facilitated by the design of the solverLand
function. This function is structured to handle both initial and subsequent pulls from the pusherLaminated
proxy, which manages the execution of jobs queued by the scheduler.
Initially, the solverLand
function is called with the isFirstTime
flag set to true
, indicating the first execution of a job. In this case, the function pulls the job using the original laminatorSequenceNumber
. The returnObjsFromPull
array is prepared with placeholders for return values, with a specific slot indicating the job's execution status (returnObjsFromPull[2]
).
Upon the next call to solverLand
, with isFirstTime
set to false
, the function adjusts to pull the next job in the sequence by incrementing the laminatorSequenceNumber
by 1. This adjustment is crucial for accessing the duplicated job, which is queued in the pusherLaminated
proxy with the next sequential number. The returnObjsFromPull[2]
is also updated to reflect the new execution status, indicating the handling of the second job.
This mechanism ensures that the solver can effectively manage the execution of duplicated schedulers by leveraging the sequential nature of job queuing in the pusherLaminated
proxy. The conditional handling of the laminatorSequenceNumber
and the dynamic adjustment of the returnObjsFromPull
array enable the solver to pull and execute each job in turn, ensuring that both the original and its duplicate are processed accordingly.
function solverLand(uint256 laminatorSequenceNumber, address filler, bool isFirstTime) public {
CallObject[] memory callObjs = new CallObject[](1);
ReturnObject[] memory returnObjs = new ReturnObject[](1);
callObjs[0] = CallObject({
amount: 0,
addr: pusherLaminated,
gas: 10000000,
callvalue: abi.encodeWithSignature("pull(uint256)", laminatorSequenceNumber)
});
ReturnObject[] memory returnObjsFromPull = new ReturnObject[](4);
returnObjsFromPull[0] = ReturnObject({returnvalue: ""});
returnObjsFromPull[1] = ReturnObject({returnvalue: ""});
returnObjsFromPull[2] = ReturnObject({returnvalue: abi.encode(1)});
returnObjsFromPull[3] = ReturnObject({returnvalue: ""});
returnObjs[0] = ReturnObject({returnvalue: abi.encode(abi.encode(returnObjsFromPull))});
bytes32[] memory keys = new bytes32[](2);
keys[0] = keccak256(abi.encodePacked("tipYourBartender"));
keys[1] = keccak256(abi.encodePacked("pullIndex"));
bytes[] memory values = new bytes[](2);
values[0] = abi.encodePacked(filler);
values[1] = abi.encode(laminatorSequenceNumber);
bytes memory encodedData = abi.encode(keys, values);
if (!isFirstTime) {
callObjs[0].callvalue = abi.encodeWithSignature("pull(uint256)", laminatorSequenceNumber + 1);
returnObjsFromPull[2] = ReturnObject({returnvalue: abi.encode(2)});
returnObjs[0] = ReturnObject({returnvalue: abi.encode(abi.encode(returnObjsFromPull))});
values[1] = abi.encode(laminatorSequenceNumber + 1);
}
bytes32[] memory hintdicesKeys = new bytes32[](1);
hintdicesKeys[0] = keccak256(abi.encode(callObjs[0]));
uint256[] memory hintindicesVals = new uint256[](1);
hintindicesVals[0] = 0;
bytes memory hintdices = abi.encode(hintdicesKeys, hintindicesVals);
callbreaker.executeAndVerify(abi.encode(callObjs), abi.encode(returnObjs), encodedData, hintdices);
}