This document assumes a prerequisite understanding of how Skate app works, specifically concept of dividing Stateless apps into Kernel and Periphery components.

Key Concepts

From a developer’s perspective, before we dive any further, there are three key concepts that you need to understand

1. State flows from Kernel to Periphery

Stateless app flow

For all Stateless apps, state flows unidirectionally from Skate to other periphery chains. Skate serves as the kernel that stores all critical state variables and logic, while connected periphery chains perform the necessary auxiliary actions.

2. Intents as the source

IMessageBox.sol
...
struct IntentData {
    address appAddress;
    bytes intentCalldata;
}

struct Intent {
    IntentData intentData;
    bytes32 user;
    bytes signature;
    uint256 vmType;
}
...

Intents encapsulate all the necessary information required for interactions within your app. The processIntent(IMessageBox.Intent calldata intent) function serves as the sole entry point for any crosschain interaction performed on your app.

3. Tasks as the messenger for crosschain interactions.

IMessageBox.sol
...
struct Task {
    // appAddress: bytes32 as universal address for all VMs (EVM, Solana, TON, etc.)
    bytes32 appAddress;
    bytes taskCalldata;
    bytes32 user; // bytes32 for the same reason as appAddress
    uint256 chainId; // within execution environment
    // vmType: Skate convention -- 1=EVM | 2=TON | 3=Solana
    uint256 vmType;
}
...

Tasks play a crucial role in communicating state information retrieved from Skate to other periphery chains. Developers need to take note that a task only needs to be created for any intents that require crosschain interactions.

Tasks serve as a reliable source of truth for executors, facilitating the transmission of information from Stateless apps to periphery chains. Executors rely on tasks to execute the intents accurately and effectively. By creating tasks for necessary intents, developers enable seamless crosschain interactions and ensure that the required actions are performed on the designated periphery chains.

Kernel

For any Stateless App, it has to inherit SkateApp.sol. This abstract contract helps bootstrap most of the function calls that you need to tap into Skate and allows you to focus on building custom logic specific to your application.

processIntent()

SkateApp.sol
...
function processIntent(
    IMessageBox.Intent calldata intent
) external virtual override {
    // pass into a super function
    bytes memory data =
        address(this).functionCall(intent.intentData.intentCalldata);

    IMessageBox.Task[] memory tasks = abi.decode(
        data,
        (IMessageBox.Task[])
    );
    _messageBox.submitTasks(tasks, intent);
}
...

Stateless app execution flow

Above, we provided some diagrams to allow you to understand the flow of processIntent, this function is the crux for building any Skate app. Remember that processIntent is the only entry point for crosschain interactions.

In this function:

A. Executing the calldata provided by the intent

Based on the intent, the calldata will be a function that is defined in your kernel contract. This is why this function performs a low level solidity call on the calling contract.

SkateApp.sol
...
modifier onlyContract() {
  require(msg.sender == address(this), OnlyContractCanCall());
  _;
}
...

To strictly enforce the use of intents, ALL other write functions supposed to perform crosschain interactions should have the onlyContract() modifier

SkateNFT.sol
/// This code snippet show an example stateless NFT app create tasks.

function mint(
    bytes32 user,
    bytes32 to,
    uint256 chainId,
    uint256 vmType
)
    public
    virtual
    override
    onlyContract
    returns (IMessageBox.Task[] memory tasks)
{
    return _internal_mint(user, to, ++_tokenId, chainId, vmType);
}

function _internal_mint(
    bytes32 user,
    bytes32 to,
    uint256 tokenId_,
    uint256 chainId,
    uint256 vmType
)
    internal
    returns (IMessageBox.Task[] memory tasks)
{
    _mint(to, tokenId_, 1, ''); // actual mint of NFT
    _userToTokenIds[to].push(tokenId_);
    _tokenIdToUser[tokenId_] = to;
    _tokenIdToChainId[tokenId_] = chainId;
    emit Minted(to, tokenId_);
    bytes32 peripheryContract = chainIdToPeripheryContract(vmType, chainId);

    // creates a task.
    tasks = new IMessageBox.Task[](1);
    tasks[0] = IMessageBox.Task(
        peripheryContract,
        abi.encodeWithSignature('mint(address,uint256)', to, tokenId_),
        user,
        chainId
    );
}

In the function, you can implement all the necessary application logic, similar to any other smart contract. Additionally, for any function that requires the creation of a task for crosschain interactions, it should return an array of tasks. If no crosschain interaction is needed, the function should return an empty task array.

Developers have the flexibility to customize the data used for tasks based on the specific logic of their functions. Tasks are designed to support a wide range of interactions. When crosschain interactions are required, developers must ensure that the correct function calls are specified in the periphery contract.

B. Decoding tasks that were returned from the executed calldata

Processing the tasks that were returned by the executed calldata stated in the intent.

C. Submitting these tasks to the messageBox.

Executors will be subscribing to the messageBox for any new tasks to be executed and after passing all necessary checks, the intent will be executed on Skate and broadcasted to Executors for them to execute on intended periphery chains.

Periphery

For any Stateless App, it must inherit the SkateAppPeriphery.sol contract. Since periphery contracts are designed to perform minimal peripheral activities such as transferring assets and providing lookup states derived from Skate, little development work is required here. Nonetheless, here are some key points to take note of:

SkateGateway as the Main Entry Point

The SkateGateway serves as the central entry point for all interactions with the periphery app. To ensure security and proper routing of functions, periphery write functions must adopt the onlyGateway modifier.

For non-EVM periphery contracts, please refer to TON section or Solana section

Example

SkateAppPeriphery.sol
...
/// NOTE: This is for EVM periphery only
modifier onlyGateway() {
  require(msg.sender == address(_gateway), OnlyGatewayCanCall());
  _;
}
...

This modifier ensures that only the SkateGateway can execute specific functions within the periphery app. Therefore, for all periphery write functions, it is crucial to apply the onlyGateway modifier to enforce this restriction and maintain the integrity of interactions.

Important things to take note during deployment

1. Executors need to be registered on ALL executor registries

To deploy any Stateless app, an executor registry must be specified during contract initialization. To facilitate the necessary intent execution, owners of Stateless apps must whitelist (their own) executors on Skate and all peripheral chains. This ensures that the required executors are recognized and authorized across all relevant networks.

2. setChainToPeripheryContract()

The owner of Stateless apps must call the setChainToPeripheryContract function to update the mapping of chains to periphery contracts. This allows the kernel app to directly retrieve the necessary addresses during task creation.

SkateApp.sol
...
function setChainToPeripheryContract(
    uint256 chainId,
    bytes32 peripheryContract
) external virtual override onlyOwner {
    _chainIdToPeripheryContract[chainId] = peripheryContract;
    emit PeripheryContractSet(peripheryContract, chainId);
}

function chainIdToPeripheryContract(
    uint256 vmType,
    uint256 chainId
)
    public
    view
    returns (bytes32 peripheryContract)
{
    if (vmType == 1) {
        require(
            (_chainIdToPeripheryContract[chainId]) != address(0x0),
            ZeroPeripheryContractAddress()
        );
        return
            bytes32(uint256(uint160(chainIdToPeripheryContract(chainId))));
    } else {
        require(
            (peripheryContract = _chainIdToPeripheryContractV2[chainId])
                != bytes32(0),
            ZeroPeripheryContractAddress()
        );
        return _chainIdToPeripheryContractV2[chainId];
    }
}
...