Create a Single-Chain Application Reading From a Caff Node
This guide showcases how to use Espresso Caff Nodes to enable instant token swaps inside a single rollup chain. Specifically, we create a Swap application on Rari testnet where users can swap TokenA ↔ TokenB with fast confirmation from Espresso.
The flow is the following:
User calls swap() on the smart contract.
The Sequencer orders the transaction, Batch Poster posts to the Parent Chain.
Espresso Caff Node immediately indexes the swap result and exposes it via API.
The dApp frontend queries the Caff Node to show the updated balance instantly.
Before You Begin
Clone the repo: https://github.com/enoldev/espresso-examples, and move to the
instant-swap
folder.Install Foundry and ensure forge, cast, and anvil are available.
Have ETH on Rari testnet (for deployment and gas fees).
Ensure you have an Espresso Caff Node running (use the provided testnet endpoint or spin up your own).
The Instant Swap Application
We’ll implement a very simple Automated Market Maker (AMM) smart contract:
Users can deposit TokenA and TokenB into the pool.
Users can call swapAForB(amount) or swapBForA(amount).
The contract uses the constant product formula (x * y = k) for swaps.
Inspect the Code
In order to test the AMM contract, we will need to mock the ERC20 tokens. The following contract is a very simple MockERC20
:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
contract MockERC20 {
string public name;
string public symbol;
uint8 public decimals = 18;
uint256 public totalSupply;
mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;
event Transfer(address indexed from, address indexed to, uint256 amount);
event Approval(address indexed owner, address indexed spender, uint256 amount);
constructor(string memory _name, string memory _symbol) {
name = _name;
symbol = _symbol;
}
function mint(address to, uint256 amount) external {
balanceOf[to] += amount;
totalSupply += amount;
emit Transfer(address(0), to, amount);
}
function transfer(address to, uint256 amount) external returns (bool) {
require(balanceOf[msg.sender] >= amount, "ERC20: insufficient");
balanceOf[msg.sender] -= amount;
balanceOf[to] += amount;
emit Transfer(msg.sender, to, amount);
return true;
}
function approve(address spender, uint256 amount) external returns (bool) {
allowance[msg.sender][spender] = amount;
emit Approval(msg.sender, spender, amount);
return true;
}
function transferFrom(address from, address to, uint256 amount) external returns (bool) {
uint256 allowed = allowance[from][msg.sender];
require(allowed >= amount, "ERC20: allowance");
require(balanceOf[from] >= amount, "ERC20: from balance");
allowance[from][msg.sender] = allowed - amount;
balanceOf[from] -= amount;
balanceOf[to] += amount;
emit Transfer(from, to, amount);
return true;
}
}
The AMM contract includes very simple logic for token swaps (TokenA and TokenB):
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
interface IERC20Minimal { // 1.
function transferFrom(address from, address to, uint256 amount) external returns (bool);
function transfer(address to, uint256 amount) external returns (bool);
function balanceOf(address owner) external view returns (uint256);
}
contract SimpleAMM {
IERC20Minimal public tokenA;
IERC20Minimal public tokenB;
uint256 public reserveA; // 2.
uint256 public reserveB;
event LiquidityAdded(address indexed provider, uint256 amountA, uint256 amountB);
event SwapAForB(address indexed trader, uint256 amountAIn, uint256 amountBOut);
constructor(address _tokenA, address _tokenB) { // 3.
tokenA = IERC20Minimal(_tokenA);
tokenB = IERC20Minimal(_tokenB);
}
function _updateReserves() internal {
reserveA = tokenA.balanceOf(address(this));
reserveB = tokenB.balanceOf(address(this));
}
/// @notice Add liquidity (caller must approve this contract)
function addLiquidity(uint256 amountA, uint256 amountB) external { // 4.
require(amountA > 0 && amountB > 0, "zero amounts");
require(tokenA.transferFrom(msg.sender, address(this), amountA), "transferA failed");
require(tokenB.transferFrom(msg.sender, address(this), amountB), "transferB failed");
_updateReserves();
emit LiquidityAdded(msg.sender, amountA, amountB);
}
/// @notice Swap exact amountA for tokenB. Very simple pricing: amountOut = amountAIn * reserveB / (reserveA + amountAIn)
function swapExactAForB(uint256 amountAIn, uint256 minBOut) external returns (uint256 amountBOut) { // 5.
require(amountAIn > 0, "zero in");
// transfer A in
require(tokenA.transferFrom(msg.sender, address(this), amountAIn), "transferFrom A failed");
// read reserves before adding amountAIn
uint256 oldReserveA = reserveA;
uint256 oldReserveB = reserveB;
require(oldReserveA > 0 && oldReserveB > 0, "empty pool");
// Simple constant product without fees:
// amountBOut = amountAIn * reserveB / (reserveA + amountAIn)
amountBOut = (amountAIn * oldReserveB) / (oldReserveA + amountAIn);
require(amountBOut >= minBOut, "insufficient output amount");
// send B to trader
require(tokenB.transfer(msg.sender, amountBOut), "transfer B failed");
// update reserves
_updateReserves();
emit SwapAForB(msg.sender, amountAIn, amountBOut);
}
}
We declare a minimal ERC20 interface (
IERC20Minimal
) with only the functions we need: transferFrom, transfer, and balanceOf. This avoids importing the full OpenZeppelin ERC20 implementation, keeping the example lightweight.The contract stores two ERC20 tokens (TokenA, TokenB) that can be swapped.
reserveA
andreserveB
track the balances of each token held in the contract — representing the liquidity pool.The constructor sets the token addresses for TokenA and TokenB that this AMM will support.
addLiquidity
lets a user deposit both TokenA and TokenB into the pool. The caller must first approve the AMM contract to spend their tokens. Transfers the tokens in, updates reserves, and emits a LiquidityAdded event.swapExactAForB
allows a user to swap a specific amount of TokenA for TokenB. It first requires the user to approve TokenA spending. Then it pulls amountAIn into the contract
Deploy the Smart Contracts
The script/Deploy.s.sol
file contains the logic to deploy two tokens: TKA
and TKB
, and the AMM
contract. It also sends the tokens to the deployer address (the address used to deploy the contracts).
forge script script/Deploy.s.sol:Deploy \
--rpc-url https://rari-testnet.calderachain.xyz/http \
--private-key $PRIVATE_KEY \
--broadcast -vv
After executing the script, the addresses of the deployer, tokens and AMM contract will be displayed in the logs. Create the environment variables.
export AMM_ADDR=0x
export TKA=0x
export TKB=0x
export DEPLOYER=0x
Test the App
Now, you can use the Rari Testnet Caff Node to test the application:
First, check the balance of the deployer address for each token. If the deployment script has been executed correctly, the deployer address must be funded with both tokens.
cast call $TKA "balanceOf(address)(uint256)" $DEPLOYER --rpc-url https://rari.caff.testnet.espresso.network
cast call $TKB "balanceOf(address)(uint256)" $DEPLOYER --rpc-url https://rari.caff.testnet.espresso.network
Then, execute the
approve(...)
function in theTKA
smart contract (this is necessary to comply with the ERC20 standard).
cast send $TKA "approve(address,uint256)" $AMM_ADDR 1000000000000000000000 \
--private-key $PRIVATE_KEY --rpc-url https://rari.caff.testnet.espresso.network
Now, you will be able to call the
swapExactAForB(...)
function:
cast send $AMM_ADDR "swapExactAForB(uint256,uint256)" 1000000000000000000 0 \
--private-key $PRIVATE_KEY --rpc-url https://rari.caff.testnet.espresso.network
Last updated