Debug your session
What your app is doing, read without asking anyone: one command, the node's health, the hub on Monad, the SDK's typed errors. Then the failures a shipped app meets, each with its fix.
Everything the team would look at to answer “what is my app doing?” is public. The hub on Monad holds what settled, the node holds what is live, and the SDK names every refusal. This page reads them in the order that finds a problem fastest. The terminal output below is real: run on 2026-10-01 against Interlude Exchange’s Paris book, a public app on the same hub as yours. Run the same commands on your app.
One command first
status reads your app and its hub on Monad, then asks the node for its health. It needs only the app address, and it never sends a transaction.
$ npx @interludelayer-sdk/cli status 0x69b1Ff7d02fb9fc48FD0Abe39801D45D0984d5A6
== reading 0x69b1Ff7d02fb9fc48FD0Abe39801D45D0984d5A6 on https://testnet-rpc.monad.xyz
app 0x69b1Ff7d02fb9fc48FD0Abe39801D45D0984d5A6
hub 0x98922c6E5e4Bea62761C71D2401c7ec2c26eC43e
owner 0xB28E684815b095aB5Fb324214cfEa63d76F3d691
pending owner none
partition GLOBAL (the whole contract)
delegation Active
validator 0xa375CF27eD39491dB8302Ffc3dF4210Ad263eF43
epoch 1
batches 2521
last commit 2026-10-01T14:10:42.000Z (9 min ago)
expires never
ok node https://il2-eu-69b1ff7d02fb9fc4.fly.dev is up: {"app":"0x69b1ff7d…","base":{"ageMs":1098,"ok":true},"chainId":4242,"commits":{"denied":1,"err":2,"ok":1992},"committedBatches":2521, … ,"halted":null, … ,"ok":true,"pendingDiffs":0, …}delegation is the hub’s word for the session: Active serves, Challenged waits for the judges, Exiting is closing, None was never opened or has been released. batches counts what settled. A last commit up to 15 minutes old is normal: an idle node commits an empty heartbeat that often, and a busy one every few seconds (every 10 s on a node ship starts). The last line is the node’s /health, found through the control plane; --node <url> checks another one. It needs the CLI 0.2.2 or later (npm).
On 0.2.2, status does not compare the node’s app with yours: it prints ok node … is up for any URL that answers /health, another app’s node or Monad’s own RPC included. Check that "app" in that line is your address. The next CLI release compares them and exits 1 when they differ.
Every problem, with its fix
doctor reads the same things further and says what they mean: the transaction that carried the last batch, the open challenge with its step and clock, /health and /ready in words, then one finding per problem, each with what to do. It is read-only too, and exits 1 when a finding is a failure, so CI or a cron can run it.
doctor ships in the CLI release after 0.2.2. On 0.2.2, status and the /health fields below give the same facts, without the reading.
$ npx @interludelayer-sdk/cli doctor 0x69b1Ff7d02fb9fc48FD0Abe39801D45D0984d5A6
== doctor 0x69b1Ff7d02fb9fc48FD0Abe39801D45D0984d5A6, read on https://testnet-rpc.monad.xyz
On Monad (the hub)
…
delegation Active
epoch 1
batches 2521
last commit 2026-10-01T14:10:42.000Z (9 min ago)
expires never
max silence 60 min without a commit (maxBatchInterval), then anyone can forceClose 5 min later
last batch 2521, block 67277588
commit tx https://testnet.monadscan.com/tx/0x785806e3006ddaa6c0d3dc332fb7ca6fb70b35914a595d3394de40a6617007cb
dispute none open
The node https://il2-eu-69b1ff7d02fb9fc4.fly.dev (from the control plane)
serves 0x69b1ff7d02fb9fc48fd0abe39801d45d0984d5a6 (this app)
/health ok
/ready 200, taking writes
epoch 1 (the hub's)
batches 2521 committed, 0 diff(s) pending
commits 1992 landed, 2 failed, 1 refused (no token), since it started
Monad last heard 1098 ms ago
dispute idle
== findings
ok the session is Active, the node serves this app at the hub's epoch, and commits are landing.
- 1 interlude_commit call(s) were refused for want of the node's commit token.
A hosted node commits on its own, and its commit token is the control plane's. Await a send's settled, or waitSettled({ hash }), instead of calling commit().
== look further
hub events https://testnet.monadscan.com/address/0x98922c6E5e4Bea62761C71D2401c7ec2c26eC43e (Events)
the app https://testnet.monadscan.com/address/0x69b1Ff7d02fb9fc48FD0Abe39801D45D0984d5A6
explorer https://demo.interludelayer.xyz/explorer (Settles, Sessions, Disputes)
every call npx @interludelayer-sdk/cli logs --follow --node https://il2-eu-69b1ff7d02fb9fc4.fly.dev
still stuck https://github.com/Veenoway/interlude-sdk/issues (paste this output)A failure reads like this: the same app, checked against the public demo’s node by mistake. The command exits 1.
$ npx @interludelayer-sdk/cli doctor 0x69b1Ff7d02fb9fc48FD0Abe39801D45D0984d5A6 --node https://rpc.interludelayer.xyz
…
The node https://rpc.interludelayer.xyz (from --node)
serves 0xa116fcaec711c9d8f64da409cbe3af673fb9084c (ANOTHER app)
…
== findings
x the node at https://rpc.interludelayer.xyz serves 0xa116fcaec711c9d8f64da409cbe3af673fb9084c, not this app.
Each node serves one contract, and the SDK throws WrongNodeError here. Use the app and the node that ship printed, together. https://rpc.interludelayer.xyz is the public demo's. The control plane has https://il2-eu-69b1ff7d02fb9fc4.fly.dev for this app: run doctor without --node, or with --node https://il2-eu-69b1ff7d02fb9fc4.fly.dev.And with Monad’s RPC given as the node, the env-file mistake under the failures below. Monad answers /health too, so only the missing app and epoch give it away:
$ npx @interludelayer-sdk/cli doctor 0x69b1Ff7d02fb9fc48FD0Abe39801D45D0984d5A6 --node https://testnet-rpc.monad.xyz
…
The node https://testnet-rpc.monad.xyz (from --node)
serves not said: not an Interlude node
/health answers, with no app and no epoch
/ready 400, not a node's answer
== findings
x https://testnet-rpc.monad.xyz answers /health but is not an Interlude node (it says chain 10143, the base chain: a Monad RPC): it names no app and no epoch.
The control plane has https://il2-eu-69b1ff7d02fb9fc4.fly.dev for this app: run doctor without --node, or with --node https://il2-eu-69b1ff7d02fb9fc4.fly.dev.Watch every call
logs --follow subscribes to the node and prints one line per call it runs, yours and everyone else’s, as it runs them. It sends nothing.
$ npx @interludelayer-sdk/cli logs --follow --node https://il2-eu-69b1ff7d02fb9fc4.fly.dev
following wss://il2-eu-69b1ff7d02fb9fc4.fly.dev/ (interlude_subscribe "applied"). Times are when each call reached this machine, UTC. Ctrl-C to stop.Then one line per call:
<time> <status> <function> block <n> from <sender> tx <hash>The time is when the line reached your machine, not a latency. The status is ok or failed (a revert, or a halt such as out of gas). The function is the 4-byte selector, or its name with --abi out/YourApp.sol/YourApp.json. No call reached the book while this ran, so the excerpt stops at the subscription; your own clicks show up at once. --json prints each notification as the node sent it, calldata and logs included. A node that cannot be reached or refuses the subscription is an error: the command exits 1 and prints nothing on stdout.
The node’s /health
Every node answers GET /health and GET /ready without a rate limit. This is what status printed on one line, with the fields below picked out:
$ curl -s https://il2-eu-69b1ff7d02fb9fc4.fly.dev/health \
| jq '{ok, halted, app, epoch, validator, committedBatches, pendingDiffs, commits, base, dispute, parked: .lock.parked, sendGated}'
{
"ok": true,
"halted": null,
"app": "0x69b1ff7d02fb9fc48fd0abe39801d45d0984d5a6",
"epoch": 1,
"validator": "0xa375cf27ed39491db8302ffc3df4210ad263ef43",
"committedBatches": 2521,
"pendingDiffs": 0,
"commits": {
"denied": 1,
"err": 2,
"ok": 1992
},
"base": {
"ageMs": 1140,
"ok": true
},
"dispute": {
"moves": 0,
"poster": "control",
"scannedTo": 67280499,
"state": "idle"
},
"parked": 0,
"sendGated": false
}
$ curl -s -o /dev/null -w '%{http_code}\n' https://il2-eu-69b1ff7d02fb9fc4.fly.dev/ready
200- ok, halted
- ok is false exactly when halted holds a sentence: the node refuses writes and says why. Reads keep working.
- app
- The contract this node serves. Not yours: you are on the wrong node, and the SDK throws WrongNodeError.
- epoch, validator
- The hub session the node runs. An epoch below the hub's means the app was delegated again and this node serves a session that is over.
- committedBatches
- Batches this session has committed. The same number as batches in status once the last commit landed.
- pendingDiffs
- Changed slots waiting for the next commit. Above zero for a few seconds is normal; climbing while the last commit ages is commits not landing.
- commits.ok, .err
- Commits this process landed and attempts that failed, since it started. A few failures among thousands are retries that worked.
- commits.denied
- interlude_commit calls refused for want of the node's commit token. A hosted node commits on its own; await settled instead.
- base.ok, base.ageMs
- How long since the node last heard from Monad's RPC. ok turns false past 15 s; commits fail until it answers.
- dispute.state
- idle, or the node's responder in a dispute (defending, indefensible). A node that serves through disputes adds paused, queuedBatches, worstCaseEnd and lastOutcome.
- lock.parked
- Sends that waited behind a full batch since the node started. One that never finds room comes back as NodeBusyError, kind "batch".
- sendGated
- true when the node only takes transactions from a holder of its send token. Never on a node ship starts.
- GET /ready
- 200 while the node takes writes, 503 while it refuses them. /health always answers 200, so a platform does not restart a node that is only waiting.
On Monad: the hub and the explorer
The node can be wrong; the hub is the record. Its events on MonadScan are every app’s, and each one about a session carries the app as its first indexed argument: Committed (the batch, its state root and transaction root), BatchLog (its transactions, whose signed bytes are in the commit’s calldata), DelegationOpened, DelegationClosing, Challenged, ResolutionVoted and ChallengeResolved. The commit transaction doctor prints is the quickest way in: its logs are the batch your users’ calls settled in.
The explorer reads the same hub for every app on it: Settles (each commit, newest first), Sessions (the open ones), Disputes (every challenge, by session). An app it does not know by name shows as Partner app, with its short address; that name links to the app’s own page on MonadScan.
Live vs settled
A number that differs between the node and Monad is not a bug until the next commit. The node holds the live value, Monad the last committed one, and pendingDiffs is the gap between them. A call is on Monad once its settled resolves (Live vs settled).
const live = await interlude.read("currentScore"); // the node, now
const onMonad = await interlude.readSettled("currentScore"); // Monad, at the last commit
const { pendingDiffs, committedBatches } = await interlude.status();
const { hash, settled } = await session.send("play");
const { batchIndex, settlementHash } = await settled; // the batch, and the Monad tx that carried itsettlementHash is a MonadScan transaction. logs --follow shows the same hash on the node, so one call can be followed from the click to Monad.
What a dispute looks like to your users
Anyone can challenge a batch on Monad. Until the judges rule, nothing after it settles, and your users should be told in a sentence, not find out from a number that stops moving. <DisputeBanner /> is that sentence, or nothing while there is no dispute; interlude.dispute() is the same state without React.
- verifying
- Verification in progress: this app keeps running, settlement on Monad resumes by 16:30. What you do meanwhile is confirmed by this app and settles after the verdict; it is rolled back if fraud is proven or no verdict comes in time.
- frozen
- Verification in progress: this app is paused until the judges rule
- dismissed
- Claim rejected by the judges: the challenger's bond was burned
- fraud
- Fraud proven: the validator was slashed and state after batch 40 was unwound
From your side: status says Challenged, doctor names the challenged batch, the step and when the move’s clock runs out, and /health’s dispute.state leaves idle. You have nothing to send: the node answers the challenge itself. What happens to the calls made meanwhile is on When your app is challenged.
DisputeBanner, useDispute and interlude.dispute() ship in the SDK release after 0.2.2. On 0.2.2, poll the node’s /health and show a line while dispute.state is not idle.
The SDK’s errors, by where the call stopped
Every error the SDK throws extends InterludeError and carries a sentence meant for a person. Where it stopped tells you where to look. On 0.2.2 three failures still come through as viem’s own errors, and the next SDK release names them: a read on a node that does not answer (“HTTP request failed”), a wrong URL in the env file, and a hub revert from delegateAll, which shows only its selector. All three are under the failures below.
import { InterludeError, NodeBusyError, SessionExpiredError } from "@interludelayer-sdk/sdk";
try {
await session.send("play");
} catch (error) {
if (error instanceof SessionExpiredError) session = await interlude.openSession({ wallet, scope });
else if (error instanceof NodeBusyError && error.retryable) retryIn(error.retryAfterMs ?? 1_000);
else if (error instanceof InterludeError) show(error.message); // error.name says which
else throw error;
}Stopped in the SDK, before anything was sent
Nothing reached the node and the wallet signed nothing.
- WrongChainError
The wallet is on another network than Monad testnet.
Pass ensureChain: true to openSession or the provider, or call interlude.ensureChain(wallet).
- SessionUnusableError
A wallet client with no account, a grant with no scope, or a stored session that does not match.
Build the wallet with its account; pass a scope.
- InvalidScopeError
The scope names a function the ABI does not have.
Fix the name, or pass the full signature for an overload.
- SelectorOutOfSessionScopeError
The call is not in the grant's scope (the SDK checks before signing).
Open a session whose scope includes it.
- SessionExpiredError
The grant's hour ran out.
openSession again: the user signs once more. autoRenew on the React provider does it a minute early.
- SessionRevokedError
This client revoked the grant with revokeAll.
Open a new session (on a running node, only after the app is delegated again).
- NotAnInterludeNodeError
The node URL answers as Monad itself: the env file's two URLs swapped, or the same one twice. Checked beside the first read (next SDK release; 0.2.2 returns Monad's value).
Set the node URL to the one ship printed; interlude sessions get <app> prints it again.
- BaseChainMismatchError
The base client is the node, answers as another chain than it was created for, or reaches a chain with no contract at the app (next SDK release).
Give the base client Monad's RPC, https://testnet-rpc.monad.xyz, and check the app address.
- HubRevertError
delegateAll, delegateKey, undelegate or releaseStake would revert on the hub. The SDK simulates them first, so nothing was sent (next SDK release; 0.2.2 shows only the selector, see below).
errorName says which: FeeNotPaid, EmptyDelegation, StakeStillLocked, ValidatorAtCapacity. Each has its fix under the failures below.
Refused by the node
The call went to the node and did not run there, unless the row says otherwise.
- NodeUnreachableError
The node did not answer at all: down, starting, or the wrong URL. The message names the URL. send, status and waitSettled throw it. On 0.2.2, read and watchRead pass viem's "HTTP request failed" through instead, which is not an InterludeError; the next SDK release throws NodeUnreachableError there too. readSettled reads Monad, so a Monad RPC failure stays viem's.
Check the URL. Right after ship, a 502 for a few minutes is the image building. Then doctor.
- WrongNodeError
The node serves another app.
Use the app and the node ship printed, together. interlude sessions get <app> prints the node.
- NodeBusyError, kind "batch"
The open batch is full until the next commit; send already retried.
Try again in a moment. retryable: false means this one call writes more than a batch holds (233 slots): split it.
- NodeBusyError, kind "limit"
A rate limit: 6,000 calls in 10 s per IP, 100 transactions a second per signer, or the node's 64 requests in flight taken.
Slow down. retryAfterMs says how long, when the node named it.
- WriteOutsideDelegationError
The call writes state the delegation does not cover: another contract, or a slot never delegated.
Mark the variable @custom:interlude, gen, and ship the new build. Another contract's storage cannot be written from a session: keep that write on Monad.
- SettlementPausedByDisputeError
A dispute pauses settlement and the node takes no new calls until the verdict (next SDK release).
Wait for the verdict; the DisputeBanner says when.
- ResultUnavailableError
The call ran, but its response was lost and the receipt carries no return data.
Do not send it again: read the state it changed.
Reverted by the session machinery
Delegatable or the session check refused the call, on the node or on Monad.
- SessionEpochStaleError
The grant names an epoch the app no longer accepts. pinnedByNode false: the user revoked. true: the node pinned its block before the user's last revoke.
False: open a new session. True: signing again does not help; it clears when the app is delegated again.
- SelectorOutOfSessionScopeError (self-call)
The grant covers the function, but the app reached it through this.other(), which changes msg.sig.
Call each function under its own grant, or open the session with anyFunction.
- SessionNotSignedByGranterError
Usually a grant signed for another app or another chain id.
Check that base is Monad and app is the address the user saw.
- WrongSessionKeyError
The stored grant and the key that signed the call drifted apart.
Open a new session.
- PrivilegedSelectorError
No grant reaches the delegation controls, ownership or the hub callbacks.
Call it from the owner's wallet, on Monad.
- DelegatedWritesDisabledError
The write went to Monad instead of the node.
Send it through the session, to the node URL.
- NotRegisteredError
The app wrote a delegated variable it never registered.
Call _registerInterludeSurface() in the constructor, and redeploy.
- DelegatableError
Another Delegatable revert, by name, with its arguments.
The name says which rule; the SDK README's Errors table has each.
Reverted by your contract
Your own rules, as they would revert on any EVM chain.
- AppRevertError
Your error, by name, with its decoded arguments.
Treat it like any revert: it is your rule, not Interlude's.
- UnrecognisedRevertError
Revert data no error in the ABI matches.
Pass the app's full ABI, errors included, to createInterludeClient.
While waiting for Monad
The call ran on the node. These come from settled and waitSettled, not from send.
- SettlementTimeoutError
No commit carried the call within the wait (60 s by default).
Commits may have stopped: run doctor. pendingDiffs climbing means they are not landing.
- SettlementLostError
The node no longer knows the call: a restart dropped what it had not committed. The next SDK release adds reason: "dispute" when a dispute ended without the queue landing.
Restart: send it again. Dispute: it does not stand; tell the user.
- SettlementPausedError
A wait with its own timeoutMs ended while a dispute holds settlement (next SDK release).
Do not send it again: it settles after the verdict. Wait longer.
Each one, with its fields, is on Errors and in the SDK’s README on npm.
The failures a shipped app meets
- FeeNotPaid (0x0590fdf3)
delegateAll reached the hub without the validator's delegationFee: 0.01 MON on the live terms. ship pays it; you meet this re-opening an app after undelegate and releaseStake. On 0.2.2 the SDK does not name it: a local key throws "reverted with the following signature: 0x0590fdf3", and a browser wallet can send the transaction anyway and mine the revert. The next SDK release simulates first and throws HubRevertError "FeeNotPaid".
Forward the fee: interlude.delegateAll(ownerWallet, { value: parseEther("0.01") }). doctor prints the fee when the app is not delegated.
- EmptyDelegation (0x77b4ebc8)
The delegation names no storage. The constructor never called _registerInterludeSurface() (init and check do not catch it), or delegateKey on a contract whose surface is global.
Call _registerInterludeSurface() in the constructor and ship the fixed build. On a hosted node, mark storage global: it serves the whole contract.
- SelectorOutOfSessionScope
The grant does not name the function, or the app called itself with this.other().
Add the function to scope. For a self-call, give each function its own grant, or anyFunction.
- A wrong URL in the env file
0.2.2 names none of these, and each has its own symptom. The node URL set to Monad's RPC: read() quietly returns Monad's value, and status() throws MethodNotFound "interlude_session". The base RPC set to the node URL: readSettled returns the live value, and openSession throws "Missing or invalid parameters". A base client on another chain: readSettled throws '"owner" returned no data ("0x")' and openSession '"hub" returned no data ("0x")'.
Compare the node URL with the one ship printed (interlude sessions get <app> prints it again), and the base RPC with https://testnet-rpc.monad.xyz. doctor <app> --node <url> fails a URL that is not an Interlude node, and logs --follow --node <url> refuses one ("has no interlude_subscribe"). The next SDK release throws NotAnInterludeNodeError and BaseChainMismatchError instead.
- WrongNode
The node URL belongs to another app: the public demo's https://rpc.interludelayer.xyz, or a previous deployment's node.
Use the pair ship printed. interlude sessions get <app> prints the node; doctor says which app a node serves.
- A stale epoch
SessionEpochStaleError. The user revoked (revokeAll), or the node pinned its block before that revoke. doctor also shows a node whose own epoch is behind the hub's: it serves a session that is over.
After a revoke, open a new session. With pinnedByNode, or a node behind the hub, the app needs a new delegation; signing again does not help.
- A node at capacity
At ship: ValidatorAtCapacity (0xe90bcd65: the validator has no free delegation) or HTTP 503 "hosted capacity is full" from control. At run time: NodeBusyError, kind "batch" (the open batch is full until the next commit) or kind "limit".
At ship: control still names the app it deployed and prints a retry command; run it later. At run time: the SDK already retried; lower the rate, or split a call that writes more than 233 slots.
- Control's 429 budgets
Control relays every hosted commit and pays its gas, from hourly budgets (in the hosted configuration: 300M gas per partner app, a 1B pool all partner apps share, one commit a second, 3,600 an hour; LIMITS.md has each). Past one, control answers 429 and the commit waits: pendingDiffs climbs, the last commit ages, settled takes longer. Writes still execute.
The node waits and sends again; the budget frees up within the hour. After 30 minutes of failed commits the node refuses writes (/ready 503). An app that needs more than the partner ceiling: open an issue with its address and its rate.
A wallet, viem or MonadScan without the hub’s ABI shows these reverts as four bytes. Search for the selector:
- 0x0590fdf3FeeNotPaid
Raised by the hub. delegateAll without the validator's delegationFee.
- 0x77b4ebc8EmptyDelegation
Raised by the hub. The delegation names no storage: _registerInterludeSurface() was never called.
- 0x7f6699f6StakeStillLocked
Raised by the hub. releaseStake before the challenge window after the session closed has passed.
- 0xe90bcd65ValidatorAtCapacity
Raised by the hub. The validator has no free delegation.
- 0x5c90ea1bDelegatedWritesDisabled
Raised by your app (Delegatable). A write sent to Monad while a session holds that state: send it through the session.
Every quota, with the setting behind it and the exact refusal, is in LIMITS.md.
When to open an issue
When doctor reports something none of the fixes above covers: a node that stays down more than a few minutes after ship, a halt whose reason is neither failing commits nor an ended session, commits failing again and again, a challenge you did not expect, or a settled value you can show is wrong. Not for an AppRevertError (that is your contract’s rule) or a budget refilling within the hour.
Open it on GitHub with what someone needs to see it without asking you: the doctor output (or status and /health on 0.2.2), the app address and node URL, the transaction hash from the error or from logs, the time in UTC, and the SDK and CLI versions. Every one of those is public, so the issue can be too.
