Skip to main content

How Smart Transactions Work ?

Smart transactions core contracts are a set of smart contracts that allow for the creation and management of transactions with runtime introspection properties. These properties allow smart transactions to enforce programmatic assertions about how they are executed on-chain.

Users queue up calls in the present for execution in the future by pushing them to an on-chain “Mempool” LaminatedProxy, via a unified router contract (Laminator). Users can configure the calls in several ways, such as preventing frontruns and backruns, arranging them to be executable only after a delay, enforcing who can execute them in a bundle, and making assertions about which code can or cannot be run around them in the bundle.

For example, a smart transaction can be created to transfer a balance of tokens to a specific address when a certain time is reached, or to create continuous scheduled transfers of funds to a specific address (I.e. like a subscription payment). Stxns can perform peer-to-peer token swaps based on Coincidence of Wants (CoW). They can also enforce that no undesirable behaviors such as calls to unaudited addresses are executed around each call. Stxns can even incentivize other bots to manage on-chain funds and execute on-chain actions, with arbitrary guardrails on the conditions to execute a trade (such as slippage control).

  • The LaminatedProxy is the core component of smart transactions, serving as a queue for transactions with specific conditions.
  • The CallBreaker is responsible for managing and executing the sequence of transactions, ensuring they meet the conditions set by the user and allowing users to introspect the state of the transaction.

Key Components

Mempool (LaminatedProxy)

At the core of smart transactions is a mempool, known as the LaminatedProxy. It acts as a holding area where users queue up calls for future execution. LaminatedProxy differs from traditional mempools in that it stores calls with specific conditions.

The push, pull, and execute are the three primary functions of the LaminatedProxy, enabling the dynamic management and execution of deferred function calls based on specific conditions or timing.

Push Function

The push function allows users or other contracts to enqueue a deferred function call to be executed after a specified delay. This function takes an encoded CallObject and a delay parameter as inputs. The CallObject contains details about the function call to be deferred, including data like the target address, the amount of gas to use, and any value to be sent with the call. The delay parameter specifies the number of blocks to wait before the function call can be executed. Upon successful execution, the function assigns a unique sequence number to the deferred call, stores it in the deferredCalls mapping, and emits a CallPushed event.

Pull Function

The pull function is designed to execute a deferred function call identified by its sequence number. It first performs a series of checks to ensure that the call is ready to be executed, including whether it has been initialized and not yet executed, and whether the current block number is equal to or greater than the firstCallableBlock of the deferred call. If all checks pass, the function sets the call as executed, updates the currently executing sequence number and call index, and then executes all function calls contained within the CallObjectHolder. It emits a CallableBlock event before execution and a CallPulled event after successful execution.

Execute Function

The execute function allows for the execution of a CallObject. It is restricted to be called only by the owner of the contract. This function decodes the provided input into an array of CallObjects and then executes each call in the array through the _executeAll internal function. The execute function is particularly useful for scenarios where immediate action is required, bypassing the need for enqueuing and waiting for a delay period.

Call Utilities

Users can also utilize utility functions such as copyCurrentJob to duplicate a transaction with a specific delay and condition for use cases where the same call should be repeated pending the successful execution of the first call (e.g. schedulers). Additionally, functions like cancelAllPending and cancelPending allow for the cancellation of queued transactions, either individually or in bulk, providing users with greater control over their transaction queue.

These functions collectively facilitate the flexible scheduling and execution of function calls, enabling users to leverage smart transactions for a wide range of applications, from automated task execution to conditional transaction processing.

Conditional Execution

Users can configure their transactions with various conditions. These can be a huge variety of factors - like ensuring an order of transactions, preventing front-running, ensuring an exchange rate, and more. The transaction will only be executed if and when a predefined condition is met.

Execution and Verification (CallBreaker)

The CallBreaker is responsible for managing and executing the sequence of transactions, making sure they meet the conditions set by the user. Transactions are then verified to ensure everything went according to plan.

Call Verification

The verify function is a critical component of the contract, designed to execute a set of pre-queued calls and compare their actual return values against expected return values provided as part of the function's arguments. This function plays a pivotal role in ensuring that a sequence of operations meets expected outcomes before any permanent changes are committed.

Verify first decodes the list of call objects and their expected return values from the provided arguments. It then resets and populates internal storage structures used for tracking the calls, return values, and associated data; it also iterates over each call, executing it and verifying its actual return against the expected return.

When the calls are verified, it cleans up temporary storage and closes the verification portal after all verifications are done.

Call Introspection

There are several functions for inspecting call objects and enforcing transaction ordering within a smart transaction bundle. The following functions "allow" users to prevent frontruns and backruns within a transaction bundle through ordering invariants (in addition to existing call invariants).

getPair - This function fetches a pair consisting of a CallObject and a ReturnObject from the contract's storage, based on a given index. getCompleteCallIndexList - This function is designed to search through a list of calls (the callList) for all occurrences of a specific call, identified by the CallObject. It returns an array of indices representing the positions where the given call appears in the list. getCallIndex and getReverseIndex - fetches indices from the hintIndicesStore based on a CallObject, validating the correctness of these indices against the call's actual position in the callList. getCurrentlyExecuting - This function returns the index of the currently executing call.

Tipping

The receive() function, used for handling Ether transfers to the contract, acts as a mechanism for receiving tips. When Ether is sent to the contract without calling any specific function, the receive() function is invoked automatically. It retrieves an address (presumably the solver) from the transaction's associated data store (via. ``fetchFromAssociatedDataStore`) using a predetermined key ("tipYourBartender"), and it forwards the received Ether to the address fetched from the associated data store.

Lifecycle of a Smart Transaction

1. Transaction Queuing

Users initiate the process by queuing their transaction calls via the LaminatorProxy. This stage involves specifying the conditions under which each call should be executed. These conditions, along with the calls themselves, are stored within the proxy, awaiting the fulfillment of their triggering criteria.

// send proxy some eth
pusherLaminated.transfer(1 ether);

// The user's queued transactions
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);

2. Conditional Logic Application

The smart transaction protocol not only applies the specified conditions to these queued transactions but also ensures that each transaction has built-in conditions to revert if certain criteria within the call trace are not met. If any of these conditions fail, the entire bundle reverts to maintain the integrity and intended outcomes of the transaction sequence.

function takeSomeAtokenFromOwner(uint256 atokenamount) public onlyOwner {
require(CallBreaker(payable(callbreakerAddress)).isPortalOpen(), "CallBreaker is not open");

if (!balanceScheduled) {
CallObject memory callObj = CallObject({
amount: 0,
addr: address(this),
gas: 1000000,
callvalue: abi.encodeWithSignature("checkBalance()")
});
emit LogCallObj(callObj);
assertFutureCallTo(callObj);

balanceScheduled = true;
}

imbalance += atokenamount * exchangeRate;
require(atoken.transferFrom(owner, getSwapPartner(), atokenamount), "AToken transfer failed");
}

function checkBalance() public {
require(imbalance == 0, "You still owe me some btoken!");
balanceScheduled = false;
}

3. Execution after Specified Conditions

When the specified time or conditions are met, the transactions are pulled from the LaminatedProxy for execution. This step, as well as the next one, are both performed by the solver. The solver has to have knowledge of the user's transaction bundle in order to execute pull without a revert from the CallBreaker's verification process.

callObjs[0] = CallObject({
amount: 0,
addr: pusherLaminated,
gas: 1000000,
callvalue: abi.encodeWithSignature("pull(uint256)", laminatorSequenceNumber)
});

ReturnObject[] memory returnObjsFromPull = new ReturnObject[](3);
returnObjsFromPull[0] = ReturnObject({returnvalue: ""});
returnObjsFromPull[1] = ReturnObject({returnvalue: abi.encode(true)});
returnObjsFromPull[2] = ReturnObject({returnvalue: ""});

4. Verification of Execution

After execution, a verification process checks whether the transactions have been executed according to the specified conditions. The CallBreaker iterates through each call made by the user and checks it against the return values of the solver's execution. Invariants are enforced to ensure that the solver's execution matches the user's expectations.

callbreaker.verify(abi.encode(callObjs), abi.encode(returnObjs), encodedData, hintindices);

Smart Transactions Diagram