Integrate an existing contract
A contract you already wrote: an OpenZeppelin owner, a parent contract, a struct, an ERC-20. What to inherit, what the node holds, what stays on Monad, how to test it before ship. Checked against cli 0.2.2.
First hour starts from a starter. Most teams start from a contract they already have. This page is that path, on one example with the usual parts: an OpenZeppelin owner, a parent contract that holds the ledger, a struct per player and an ERC-20 for deposits. Every snippet was checked against @interludelayer-sdk/cli and @interludelayer-sdk/sdk 0.2.2, with forge 1.8 and OpenZeppelin 5.4: the Solidity compiles and its tests pass, the TypeScript type-checks.
The contract, before
Players deposit a token for credits. play() costs entryFee credits, which go into a pot, and every tenth game takes the pot. play() is the call you want fast. deposit and withdraw move the token.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
/// The ledger, in a base contract the team already had.
abstract contract ArcadeBank {
mapping(address => uint256) internal credits;
uint256 public totalGames;
function creditsOf(address who) external view returns (uint256) {
return credits[who];
}
}// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import {Ownable, Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {ArcadeBank} from "./ArcadeBank.sol";
contract Arcade is Ownable2Step, ArcadeBank {
using SafeERC20 for IERC20;
struct Player {
uint64 games;
uint64 wins;
uint128 points;
}
IERC20 public immutable token;
uint256 public entryFee;
uint256 public pot;
mapping(address => Player) public players;
event Played(address indexed player, uint256 game, bool won);
constructor(IERC20 token_, uint256 entryFee_, address initialOwner) Ownable(initialOwner) {
token = token_;
entryFee = entryFee_;
}
function deposit(uint256 amount) external {
token.safeTransferFrom(msg.sender, address(this), amount);
credits[msg.sender] += amount;
}
function withdraw(uint256 amount) external {
credits[msg.sender] -= amount;
token.safeTransfer(msg.sender, amount);
}
/// Every tenth game takes the pot.
function play() external returns (bool won) {
credits[msg.sender] -= entryFee;
pot += entryFee;
totalGames += 1;
Player storage p = players[msg.sender];
p.games += 1;
won = totalGames % 10 == 0;
if (won) {
p.wins += 1;
p.points += uint128(pot);
credits[msg.sender] += pot;
pot = 0;
}
emit Played(msg.sender, totalGames, won);
}
function setEntryFee(uint256 fee) external onlyOwner {
entryFee = fee;
}
}1. Vendor the contracts
npm i -D @interludelayer-sdk/cli@^0.2.2
npx @interludelayer-sdk/cli init # exits 1: nothing inherits Delegatable yetIt writes lib/interlude and the @interludelayer/contracts/ remapping. The project needs solc 0.8.28 or later and evm_version cancun or later.
2. Drop OpenZeppelin’s owner
Delegatable next to Ownable2Step does not compile:
Error (6480): Derived contract must override function "owner". Two or more base classes define function with same name and parameter types.
Error (5883): Event with same name and parameter types defined twice.The same errors come back for transferOwnership, acceptOwnership, pendingOwner, the onlyOwner modifier and both ownership events. No override fixes it: Delegatable’s are not virtual. Remove the import, Ownable2Step from the inheritance list, the Ownable(initialOwner) call and the initialOwner argument. Keep every onlyOwner: it now means Delegatable’s owner, which is two-step too (transferOwnership, then acceptOwnership from the new owner, pendingOwner() in between). What changes: the deployer is the first owner (with ship, Interlude’s key until you accept), a stranger gets OnlyOwner() instead of OwnableUnauthorizedAccount(address), and there is no renounceOwnership.
3. Sort the storage
A node commits your contract’s slots and nothing else. Every variable a fast call writes has to be delegated. What it only reads can stay on Monad, where the node reads it at the block the session opened.
- credits (parent)
- Delegated. play debits it.
- totalGames (parent)
- Delegated. play counts.
- pot
- Delegated. play pays into it.
- players (struct)
- Delegated, once packed into one word. play updates it.
- entryFee
- Monad. play only reads it.
- token
- Bytecode. It is immutable: nothing to move.
- the token's balances
- Monad, always. They are another contract's storage.
The four are global: play debits a player and fills the shared pot in one call, so they have to be one partition, and the hosted node serves only global. Leaving entryFee on Monad has a cost: setEntryFee changes Monad at once and the node at the next delegation. OpenZeppelin’s Pausable is the same case: _paused is private, so it cannot be marked, and the node reads it at the block the session opened. pause() on Monad does not stop an open session; only undelegate does.
Inherited storage
The annotation goes where the variable is declared, in the parent. In cli 0.2.2, gen reads annotations only from the file of the contract you name: a parent in another file is skipped without an error, check passes, and the first play on the node is refused. Until that is fixed, move the parent into the same file, as below, and check that gen lists every variable you annotated.
Storage declared in a library you do not edit cannot carry the comment at all. OpenZeppelin ERC20’s _balances and _totalSupply are private, so _mint, _burn or _transfer inside a session call is refused by the node (WriteOutsideDelegationError, naming the slot). OpenZeppelin’s ReentrancyGuard is fine: its slot is back to its value when the call ends, so nothing outside the delegation changed.
Structs
gen 0.2.2 delegates a mapping only to uint256, int256, bytes32, address or bool. A struct, even one that fits in one slot, a uint128 and a nested mapping are refused:
error players is mapping(address => struct Arcade.Player). A delegated mapping has to hold a single 32-byte value, because a commit moves one slot at a time. Keep this one on the base chain, or flatten it into a mapping this can express.Pack the struct into one uint256 (playerWords), keep Player as a memory type, and give the external players the outputs the old public getter had, so the selector and the ABI do not move. Two small scalars sharing a slot are refused the same way: make each a uint256.
A nested mapping has no packing to fall back on: flatten it. mapping(address => mapping(uint256 => bool)) cleared becomes mapping(bytes32 => bool) keyed by keccak256(abi.encode(who, id)), which gen accepts, and a cleared(address,uint256) view that hashes the same key keeps the old getter’s selector and ABI.
The ERC-20
The token is another contract, so its storage never reaches the node. A transfer inside a session call is refused (write to 0x… outside the delegated app); reading the token works, at the block the session opened. So deposit and withdraw stay Monad transactions, and since they write credits they only go through while no session is open (DelegatedWritesDisabled otherwise). Money that has to move during a session needs custody in a contract that is never delegated, and an operator key that credits the ledger on the node, keyed by deposit id so a retry cannot credit twice. Interlude’s Kandle tables work that way.
4. Guard the writers, read the user with _actor()
- 01
whenNotDelegated on every writer
whenNotDelegated(Types.GLOBAL)on every function that writes delegated state:play,deposit,withdraw,seed. Forget one and a Monad call writes a slot the node holds too: the next commit fails onoldValueand the session stalls untilforceClose. - 02
_actor() where msg.sender was
In every function a session reaches. Inside a session call
msg.senderis the app itself. - 03
Keep the money out of sessions
depositandwithdrawkeepmsg.sender, because they move the user’s tokens on Monad, and go into_isSessionBlockedso no grant reaches them. - 04
Carry the balances over
seedmoves balances from the old deployment, from[[setup]]. A deployed contract cannot be adopted in place: delegated storage is declared at construction.
5. Generate, inherit, register
gen compiles the project, so first inherit Delegatable directly, after ArcadeBank in the inheritance list, with no registration yet:
npx @interludelayer-sdk/cli gen --contract Arcade
== reading Arcade's storage layout
credits slot 0 one partition, whole mapping
totalGames slot 1 one slot
pot slot 3 one slot
playerWords slot 4 one partition, whole mapping
ok wrote src/ArcadeInterludeSurface.solThen put ArcadeInterludeSurface where Delegatable was. Keep every other base in the list (the parent here; Pausable, ReentrancyGuard or whatever yours has): the hint gen prints lists only the surface. Call _registerInterludeSurface() in the constructor, and delete src/ArcadeBank.sol now that ArcadeBank lives in Arcade.sol. The contract, after:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {Delegatable} from "@interludelayer/contracts/Delegatable.sol";
import {IInterludeHub} from "@interludelayer/contracts/interfaces/IInterludeHub.sol";
import {Types} from "@interludelayer/contracts/interfaces/Types.sol";
import {ArcadeInterludeSurface} from "./ArcadeInterludeSurface.sol";
/// The parent, moved into this file: cli 0.2.2 reads annotations from this file only.
abstract contract ArcadeBank {
/// @custom:interlude global
mapping(address => uint256) internal credits;
/// @custom:interlude global
uint256 public totalGames;
function creditsOf(address who) external view returns (uint256) {
return credits[who];
}
}
contract Arcade is ArcadeBank, ArcadeInterludeSurface {
using SafeERC20 for IERC20;
struct Player {
uint64 games;
uint64 wins;
uint128 points;
}
IERC20 public immutable token; // bytecode, not storage
uint256 public entryFee; // stays on Monad
/// @custom:interlude global
uint256 public pot;
/// @custom:interlude global
mapping(address => uint256) internal playerWords; // a Player, packed into one word
event Played(address indexed player, uint256 game, bool won);
constructor(IInterludeHub hub_, IERC20 token_, uint256 entryFee_) Delegatable(hub_) {
token = token_;
entryFee = entryFee_;
_registerInterludeSurface();
}
function deposit(uint256 amount) external whenNotDelegated(Types.GLOBAL) {
token.safeTransferFrom(msg.sender, address(this), amount);
credits[msg.sender] += amount;
}
function withdraw(uint256 amount) external whenNotDelegated(Types.GLOBAL) {
credits[msg.sender] -= amount;
token.safeTransfer(msg.sender, amount);
}
/// Every tenth game takes the pot.
function play() external whenNotDelegated(Types.GLOBAL) returns (bool won) {
address who = _actor();
credits[who] -= entryFee;
pot += entryFee;
totalGames += 1;
Player memory p = _player(who);
p.games += 1;
won = totalGames % 10 == 0;
if (won) {
p.wins += 1;
p.points += uint128(pot);
credits[who] += pot;
pot = 0;
}
_setPlayer(who, p);
emit Played(who, totalGames, won);
}
/// Same selector and outputs as the getter `mapping(address => Player) public players` had.
function players(address who) external view returns (uint64 games, uint64 wins, uint128 points) {
Player memory p = _player(who);
return (p.games, p.wins, p.points);
}
function setEntryFee(uint256 fee) external onlyOwner {
entryFee = fee;
}
/// Balances carried over from the old deployment, before the first delegation.
function seed(address who, uint256 amount) external onlyOwner whenNotDelegated(Types.GLOBAL) {
credits[who] += amount;
}
function _isSessionBlocked(bytes4 selector) internal view override returns (bool) {
return selector == this.deposit.selector || selector == this.withdraw.selector
|| super._isSessionBlocked(selector);
}
function _player(address who) internal view returns (Player memory) {
uint256 w = playerWords[who];
return Player(uint64(w), uint64(w >> 64), uint128(w >> 128));
}
function _setPlayer(address who, Player memory p) internal {
playerWords[who] = uint256(p.games) | (uint256(p.wins) << 64) | (uint256(p.points) << 128);
}
}6. Config and check
npx @interludelayer-sdk/cli init --contract Arcade
npx @interludelayer-sdk/cli checkinit writes "$HUB" for the hub and a <fill in: …> marker for the token and the fee; ship refuses the markers. Fill them, name yourself as owner, and seed the old balances before the delegation opens:
[app]
contract = "Arcade"
args = [
"$HUB",
"0xYourToken", # your ERC-20 on Monad testnet
"10", # entryFee
]
delegate = "all"
owner = "0xYourWallet"
[[setup]]
signature = "seed(address,uint256)"
args = ["0xAPlayer", "1000"]The tokens behind seeded credits are yours to send to the new address. Run check in CI: insert a variable above credits and it exits 1, says the layout has moved since the surface was generated, and prints the new slots.
7. Test it without a node
interlude dev needs a node binary the npm package does not ship. Everything Delegatable decides can be tested with forge test and no hub: the hub is an address until a delegation opens, its lock is one call from that address, the node is a different chain id, and a session needs one mocked read.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import {Test} from "forge-std/Test.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {Delegatable} from "@interludelayer/contracts/Delegatable.sol";
import {IInterludeHub} from "@interludelayer/contracts/interfaces/IInterludeHub.sol";
import {Types} from "@interludelayer/contracts/interfaces/Types.sol";
import {Arcade} from "../src/Arcade.sol";
contract TestToken is ERC20("Test", "TST") {
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
}
contract ArcadeTest is Test {
IInterludeHub hub = IInterludeHub(makeAddr("hub")); // no hub needed until delegation
TestToken token = new TestToken();
Arcade arcade;
address alice;
uint256 alicePk;
function setUp() public {
arcade = new Arcade(hub, token, 10);
(alice, alicePk) = makeAddrAndKey("alice");
token.mint(alice, 1_000);
vm.startPrank(alice);
token.approve(address(arcade), 100);
arcade.deposit(100);
vm.stopPrank();
}
function test_playsOnMonadBeforeDelegation() public {
vm.prank(alice);
arcade.play();
assertEq(arcade.creditsOf(alice), 90);
(uint64 games,,) = arcade.players(alice);
assertEq(games, 1);
}
/// What Monad does while a session is open: the hub has locked the partition.
function test_guardRefusesMonadWritesWhileDelegated() public {
vm.prank(address(hub));
arcade.onDelegationChanged(Types.GLOBAL, true);
vm.prank(alice);
vm.expectRevert(Delegatable.DelegatedWritesDisabled.selector);
arcade.play();
vm.prank(alice);
vm.expectRevert(Delegatable.DelegatedWritesDisabled.selector);
arcade.withdraw(10);
}
/// What the node does: another chain id, so the guard steps aside.
function test_nodeRunsTheSameBytecode() public {
vm.prank(address(hub));
arcade.onDelegationChanged(Types.GLOBAL, true);
vm.chainId(4242);
vm.prank(alice);
arcade.play();
assertEq(arcade.creditsOf(alice), 90);
}
/// A session call: the key signs, alice is the actor.
function test_sessionPlaysAsTheGranter() public {
address key = makeAddr("session-key");
bytes4[] memory scope = new bytes4[](2);
scope[0] = Arcade.play.selector;
scope[1] = Arcade.deposit.selector;
Types.SessionGrant memory g = Types.SessionGrant({
granter: alice,
sessionKey: key,
expiry: uint64(block.timestamp + 1 hours),
epoch: 0,
anyFunction: false,
selectors: scope
});
(uint8 v, bytes32 r, bytes32 s) = vm.sign(alicePk, arcade.sessionDigest(g));
bytes memory sig = abi.encodePacked(r, s, v);
vm.mockCall(
address(hub), abi.encodeCall(IInterludeHub.sessionEpochOf, (alice)), abi.encode(uint256(0))
);
vm.prank(key);
arcade.withSession(g, sig, abi.encodeCall(Arcade.play, ()));
assertEq(arcade.creditsOf(alice), 90);
// deposit is in the scope, and still refused: _isSessionBlocked wins.
vm.prank(key);
vm.expectRevert(Delegatable.PrivilegedSelector.selector);
arcade.withSession(g, sig, abi.encodeCall(Arcade.deposit, (10)));
}
function test_ownerIsDelegatables() public {
assertEq(arcade.owner(), address(this));
arcade.transferOwnership(alice);
assertEq(arcade.owner(), address(this));
vm.prank(alice);
arcade.acceptOwnership();
assertEq(arcade.owner(), alice);
vm.expectRevert(Delegatable.OnlyOwner.selector);
arcade.setEntryFee(5);
}
}8. Ship and take the contract
npx @interludelayer-sdk/cli ship --owner 0xYourWallet --out ../web/.env.local
npx @interludelayer-sdk/cli abi --out ../web/lib/arcade-abi.ts
cast send 0xYourApp "acceptOwnership()" --rpc-url https://testnet-rpc.monad.xyz --interactive
npx @interludelayer-sdk/cli status 0xYourAppship deploys, runs seed, delegates and pays the first delegation’s fee. It prints the acceptOwnership line with your app’s address. That one is a Monad transaction from your wallet, so get a little testnet MON from the faucet first. Until it lands, Interlude’s key is the owner. If status or the first call looks wrong, Debug your session reads the hub and the node the way the team would, without asking it.
9. Opening it again, and the fee
Deposits and withdrawals wait for the session to close. Closing it and opening the next one is yours, and so is the next fee:
APP=0xYourApp
HUB=0x98922c6E5e4Bea62761C71D2401c7ec2c26eC43e
RPC=https://testnet-rpc.monad.xyz
cast send $APP "undelegate(bytes32)" $(cast hz) --rpc-url $RPC --interactive
# after the challenge window (an hour), from any wallet:
cast send $HUB "releaseStake(address,bytes32)" $APP $(cast hz) --rpc-url $RPC --interactive
# deposits and withdrawals go through now. Then the fee: the fifth field.
cast call $HUB "termsOf(address)((address,uint8,uint256,uint256,uint256,uint64,uint64,uint64,uint64,uint32,uint32,uint16,bool))" \
$(cast call $HUB "defaultValidator()(address)" --rpc-url $RPC) --rpc-url $RPC | cut -d, -f5
cast send $APP "delegateAll()" --value 0.01ether --rpc-url $RPC --interactive
npx @interludelayer-sdk/cli sessions create $APPBefore releaseStake, guarded writes keep reverting and the hub answers StakeStillLocked. Without the fee, delegateAll reverts FeeNotPaid. The fee is 0.01 MON on the live terms; the owner wallet pays it and the gas, from the faucet. sessions create gets the node back for an app control deployed; for one you deployed yourself it asks the owner to sign an opt-in first (What ship does). A wallet without the hub’s ABI shows these two reverts as 0x7f6699f6 (StakeStillLocked) and 0x0590fdf3 (FeeNotPaid); the rest, by selector, are in Debug your session.
10. The frontend
Before, every game was a wallet prompt and a Monad transaction:
import "viem/window";
import { createWalletClient, custom } from "viem";
import { monadTestnet } from "viem/chains";
import { abi } from "@/lib/arcade-abi";
const ARCADE = "0x0000000000000000000000000000000000000001"; // the old deployment
// Before: a wallet prompt and a Monad transaction for every game.
export async function play() {
const [account] = await createWalletClient({ transport: custom(window.ethereum!) }).requestAddresses();
const wallet = createWalletClient({ account, chain: monadTestnet, transport: custom(window.ethereum!) });
await wallet.writeContract({ address: ARCADE, abi, functionName: "play" });
}After, the client reads the env file ship --out wrote and the ABI abi --out wrote:
import { createPublicClient, http } from "viem";
import { monadTestnet } from "viem/chains";
import { createInterludeClient } from "@interludelayer-sdk/sdk";
import { abi } from "./arcade-abi"; // interlude abi --out
export const interlude = createInterludeClient({
app: process.env.NEXT_PUBLIC_INTERLUDE_APP as `0x${string}`, // written by ship --out
abi,
node: process.env.NEXT_PUBLIC_INTERLUDE_NODE!,
base: createPublicClient({
chain: monadTestnet,
transport: http(process.env.NEXT_PUBLIC_INTERLUDE_BASE_RPC),
}),
});import "viem/window";
import { createWalletClient, custom, type WalletClient } from "viem";
import { monadTestnet } from "viem/chains";
import { GLOBAL_PARTITION } from "@interludelayer-sdk/sdk";
import { interlude } from "@/lib/interlude";
export async function connect(): Promise<WalletClient> {
const [account] = await createWalletClient({ transport: custom(window.ethereum!) }).requestAddresses();
return createWalletClient({ account, chain: monadTestnet, transport: custom(window.ethereum!) });
}
// One session for the page. openSession before every game would read Monad each time.
let session: Awaited<ReturnType<typeof interlude.openSession>> | undefined;
// After: one signature, then every game is a gasless call to the node.
export async function play(wallet: WalletClient) {
if (!session || session.isExpired() || session.granter !== wallet.account?.address) {
session = await interlude.openSession({ wallet, scope: ["play"], ensureChain: true });
}
const { result: won, settled } = await session.send("play"); // no prompt, no gas
const credits = await interlude.read("creditsOf", [session.granter]); // live, from the node
const [games, wins, points] = await interlude.read("players", [session.granter]);
return { won, credits, games, wins, points, settled }; // settled: once Monad has the game
}
// deposit and withdraw stay Monad transactions from the user's wallet,
// and only go through while no session is open.
export async function withdraw(wallet: WalletClient, amount: bigint) {
if (await interlude.readSettled("isPartitionLocked", [GLOBAL_PARTITION])) {
throw new Error("Withdrawals open again an hour after this session closes (releaseStake).");
}
await wallet.writeContract({
address: interlude.app,
abi: interlude.abi,
functionName: "withdraw",
args: [amount],
account: wallet.account!,
chain: monadTestnet,
});
}read is the node, live. readSettled is Monad, the last commit. The user needs no MON for play; deposit and withdraw are ordinary transactions from their wallet, as before (Live vs settled).
What cannot be delegated
- Another contract's storage
- An ERC-20's balances, or any write to a contract other than yours. The node refuses the call: WriteOutsideDelegationError, write to 0x… outside the delegated app.
- Storage you cannot annotate
- A library's private variables, such as OpenZeppelin ERC20's _balances and _totalSupply. _mint, _burn or _transfer inside a session call is refused.
- A mapping to anything else
- gen 0.2.2 delegates a mapping only to uint256, int256, bytes32, address or bool. Pack a struct or a uint128 into a uint256. Flatten a nested mapping into one keyed by keccak256(abi.encode(outer, inner)).
- A slot that is not the variable's alone
- Two small scalars sharing a slot, a string, a dynamic array, a struct wider than a slot.
- The contract already deployed
- Delegated storage is declared at construction. Redeploy, and carry the balances over with [[setup]].
A constant mapping key, an inherited write with no guard and the quotas a shipped app runs into are on What will not work.
