Managing dApp State (ESR)
By default a task is stateless: it receives an input, computes, returns a result, and forgets everything. The Enclave State Registry (ESR) gives your dApp durable state that persists between tasks — encrypted inside the enclave, stored on IPFS, and pointed to on-chain.
The enclave is the only thing that can read it. That is the point: your state is not visible to the node running your task, to other dApps, or to anyone reading the blockchain.
How it works
your backend enclave (SGX) chain
------------ ------------- -----
state.commit(...) --> encrypt state
compute the CID
publish to storage --> commit(key, cid, version)Only a pointer goes on-chain — the content stays encrypted. Each key has a version that increments on every commit.
State is available network-wide: your next task can run anywhere and still read what the last task committed.
The core guarantee: a task that returns SUCCESS has its state already confirmed on-chain. The attested trustedzone refuses to sign a result until every state commit the task made is verified on the registry — so a successful result and persisted state are the same event, never two things to reconcile. If the node fails to persist your commits, the task comes back as ESR_RELAY_TIMEOUT (code 37) and the order is refunded automatically: you resubmit, you never lose state silently.
Enabling it
Run ecld-init and answer yes at the Enclave State Registry step, or add this to .config.json:
"ESR": {
"enabled": true,
"contract_address": ""
}Leave contract_address empty. ecld-build fills in the correct registry for your network automatically, and refuses to build for a network where no registry is deployed.
Using it in your backend
For Nodenithy (JavaScript) the API is the same:
The API
get(key)
Returns the decrypted state, or {} if nothing was ever stored
commit(key, mutate)
Read-modify-write: mutate receives the current state and returns the new one
get_version(key)
The current version number; 0 means never written
enclave_address
The enclave's on-chain identity address — the namespace your state lives under
commit uses optimistic concurrency. If another task commits between your read and your write, it re-reads and retries automatically, so parallel tasks cannot silently overwrite each other.
One run can make at most 256 commits. Past that, commit fails with task code 38 (ESR_COMMIT_LIMIT_EXCEEDED) and the over-limit commit is not applied — the earlier ones stand. In practice you should never get close: one commit can update an entire object, so batch your writes instead of committing per field, or split very large jobs across tasks.
Two things in that table deserve a plain-language definition:
mutateis a function you supply. It receives the current state (a dict / object —{}on first write) and returns the new state to store. You transform what is there instead of overwriting blindly, which is what makes concurrent commits safe.enclave_addressis the enclave's address — think of it as the wallet address owned by the enclave. It is the enclave's own on-chain identity, derived inside the enclave; it is not your wallet and not the node's. Every state key you write lives under this address, and it is the address you pass toecld-info esr state --enclave <A>to inspect your commits.
Returning results: ecld_result
ecld_result(...) is the way to return a task's result. It replaces the older ___etny_result___ (which still works, unchanged) and adds two things:
Automatic encoding. Return anything — a
dict/list/number rides as JSON, a string as text,bytesas base64. You never encode by hand, and a result never fails on encoding.State attached by default. The result carries the ESR state of every key the task touched, so the caller gets the fresh state in the same result — no separate read task needed.
state=True (default) attaches full state for every key touched; state="meta" attaches only {key, version, cid}; state=False attaches nothing.
The task caller
Inside the enclave, task_caller() returns the wallet that submitted the task (the DO-request owner). Use it to scope state per user without trusting anything the client sends:
State ownership and permissions
The first task to commit a key becomes its owner (the task caller at that moment). Only the owner can grant or revoke access; by default nobody else can read or write it.
get/commit enforce these automatically: a caller with no permission gets a permission error, never the state. A granted writer can commit a key it does not own; a granted reader can read but not write.
Exactly-once commits — the nonce
Every commit advances the key's nonce by exactly 1 — a public, on-chain counter (a free eth_call reads it; no task, no gas). If you don't pass a nonce, the registry assigns the next value automatically. If the same task might be submitted twice (client retries, impatient users), pin the nonce yourself: read it and pass exactly the last accepted value + 1. A duplicate, stale, or skipped-ahead nonce fails the commit and the state is untouched.
A duplicate returns task code 36 (ESR_NONCE_VIOLATION) — meaning "already applied", not a failure: the first submission did the work. Passing nonce=0 explicitly is refused by the SDK: 0 is only a wire-level sentinel for "no nonce given" — it never binds the commit to the value 0; the registry auto-assigns the next number instead, and pinned nonces start at 1. You can also pre-plan a batch: submit tasks with n+1, n+2, n+3 and they apply strictly in that order. Inspect the sequence any time with ecld-info esr nonce --enclave ADDRESS --key KEY.
Reading state without paying for a task
Getting an enclave's current state normally means submitting a read task — a full order. The runner avoids that automatically: esr_read serves unchanged state straight from a cache after a free on-chain check, and places an order only when the state has actually changed. The cache is on by default — you don't enable anything.
The cache refreshes automatically from every task result (results carry attached state), so right after a write the next read is a free cache hit. On a miss the runner runs one read task and re-caches. The free on-chain check means it never serves stale state.
Controls, if you need them: enable_state_cache(file=...) to persist across restarts (default is in-memory), and disable_state_cache() to turn it off. For an authenticated read (e.g. a per-user membership check), point it at your own read function with read_code= instead of the built-in fetch.
Because a SUCCESS result already means the commits are confirmed on-chain, a client that ran the task itself does not need to wait for anything. Polling is for the other case — a web3 observer that didn't run the task and wants to react when someone else's task changes state. The runner polls the on-chain version for free (eth_calls, no orders) instead of you guessing at a delay:
Designing your keys
A key is any string — keccak256 of it identifies the slot on-chain. Use one key per independent thing so unrelated writes never conflict:
profile-<user_id>— one slot per usergame-<match_id>— one slot per matchconfig— a single global slot
Everything under one key is read and written together, so avoid putting unrelated data in the same key: two tasks touching different parts of it will contend over the same version.
Keys are also the unit of ordering. Commits to the same key are strictly serialized network-wide — if two tasks write one key at the same time, exactly one lands and the other fails visibly (retry it); commits to different keys run fully in parallel, even on different nodes. Sharded keys like profile-<user_id> therefore aren't just tidy — they are what lets your dApp's writes scale concurrently.
Testing it locally — free
ecld-test emulates the full ESR locally, on by default: StateRegistry get/commit, ownership and permissions, and task_caller() all behave exactly as on-chain, against an in-memory registry — zero orders, zero gas. State persists between runs in .ecld-esr-local.*.json, so a counter advances across invocations just like real state. Use --caller to test the ACL from another identity. See Running your dApp.
To confirm a commit actually landed on-chain (the CID and version advanced), use ecld-info esr state — free, no task. See Inspecting your dApp.
Testnet warning
On testnet there is no enclave attestation, which means the enclave identity — and therefore its wallet and its state encryption key — can be reproduced by anyone who runs your published image.
Treat testnet state as functional testing only:
do not fund testnet enclave wallets with real value
do not store real user data in testnet state
Troubleshooting
ESR is enabled but no ESR registry is deployed on <network>
That network has no registry. Build for a supported network.
Task returns CONFIG_ERROR (32)
The enclave was built without a required value. Re-run ecld-build.
Task returns ESR_GAS_LIMIT_EXCEEDED (34)
The task's commits exceeded its gas budget. Commit less often or batch state changes under fewer keys.
Task returns SECURITY_VIOLATION (35)
A state commit ran under a caller that was not the task submitter — the ownership check was bypassed. Do not forge the caller; commit as the real submitter.
Task fails with a stack trace as the result
Your backend raised during execution. The full traceback is returned as the task result — read it; the enclave is fine and the order completed.
holds a pointer that is not a CID
A previous version of your code wrote an invalid pointer.
Task returns ESR_RELAY_TIMEOUT (37)
The node failed to persist your state commits on-chain. Not your bug: the order is refunded automatically — resubmit the task.
Task returns ESR_COMMIT_LIMIT_EXCEEDED (38)
The run tried more than 256 commits (the per-run cap). Batch writes — one commit can update a whole object — or split the work across tasks. Earlier commits stand; no refund (this is dApp-side).
A complete example
The SDK ships a working end-to-end example at examples/esr-counter. Its esr_selftest() function needs no gas and no chain access, so it is the fastest way to confirm your enclave is built correctly before you start debugging.
Last updated