For the complete documentation index, see llms.txt. This page is also available as Markdown.

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

Call
What it does

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:

  • mutate is 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_address is 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 to ecld-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, bytes as 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 user

  • game-<match_id> — one slot per match

  • config — 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

What you see
What it means

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