Skip to main content

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:

  1. Deploy the Laminator Contract: This contract is responsible for managing the transactions in the mempool. It creates instances of LaminatedProxy to handle individual transactions.

  2. Deploy the CallBreaker Contract: The CallBreaker contract acts as a security mechanism, ensuring that only valid calls are executed.

  3. 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.

  4. Compute the Proxy Address: Using the Laminator contract, we compute the proxy address for the pusher, which is a necessary step before the actual deployment.

  5. Initialize the Contracts: Finally, we call the deployerLand function with the address of the pusher. 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:

  1. 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.

  2. 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.

  3. 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 the CronTwoCounter contract.

  4. 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 the pusherLaminated 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.verify(abi.encode(callObjs), abi.encode(returnObjs), encodedData, hintdices);
}