Tutorial 6: Off-Chain Storage
In Tutorial 5: Common Types and Functions, you learned how to use Merkle trees to refer to large amounts of data stored off-chain.
A zkApp account holds eight field elements of state. This tutorial shows how to keep much more than that, with only a commitment on chain, using the offchain state API that o1js provides.
The full example is in examples/zkapps/06-offchain-storage. It runs on a local blockchain, so you need no network, no faucet and no funded accounts:
- src/NumberStorageContract.ts — the offchain state declaration and the contract
- src/main.ts — a worked run: write, settle, read, update
Why Off-Chain Storage?
When you build an application for testing and local use, you can build and store a Merkle root locally.
However, when you build a production-ready, distributed zkApp, you need more than this. All users that interact with your zkApp must be able to retrieve and modify the latest state.
Any data that modifies a zkApp must be available somewhere for other users to access.
Off-Chain Storage and Decentralization
Solutions to storage span a large spectrum from inexpensive and more centralized to more expensive and more decentralized. The decentralized solutions are more expensive because they replicate and prove the stored data.
Your off-chain storage needs depend on the zkApp you are building and the guarantees you want that zkApp to have.
Offchain state in o1js
The approach in this tutorial keeps the data in actions, which are dispatched to the zkApp account and therefore available to anyone who reads the chain. The contract holds only a commitment to the data.
Three parts make it work:
- A declaration. You say which fields and maps exist and what types they hold.
- A commitment in on-chain state. One state field of type
OffchainState.Commitmentsholds a Merkle root and an action state. - A settlement proof. Writes are actions first. A proof folds the pending actions into the commitment, and a
settle()method puts the new commitment on chain.
A value is readable only after it is settled. That is the cost of the approach, and it is also what makes concurrent writes safe: an update names the value it expects to replace, and a settlement drops an update whose expected value no longer matches.
The offchain state API is exported under Experimental, so its shape can change between o1js releases. The example in this repository is built on every pull request, so it tracks the current release.
Prerequisites
- The latest version of the zkApp CLI
- Node.js 22.19.5 or later, which is what o1js 3 requires
Create the project
- Create a project with the
zk projectcommand and selectnonewhen asked about a UI:
$ zk project 06-offchain-storage
- Change to the project directory and clear the scaffolding:
$ cd 06-offchain-storage
$ rm src/Add.ts src/Add.test.ts src/interact.ts
$ zk file src/NumberStorageContract
Declare the offchain state
Declare what the contract stores. A map from an index to a value takes the place of the Merkle tree of the earlier version of this tutorial, and a field counts the entries:
import { Experimental, Field, method, SmartContract, state, UInt64 } from 'o1js';
const { OffchainState } = Experimental;
export const offchainState = OffchainState(
{
numbers: OffchainState.Map(Field, Field),
total: OffchainState.Field(UInt64),
},
{
// 2^10 entries is plenty for a tutorial and keeps proving quick.
logTotalCapacity: 10,
maxActionsPerUpdate: 4,
}
);
export class StateProof extends offchainState.Proof {}
logTotalCapacity sizes the state: 10 gives 210 entries. A smaller capacity means fewer constraints and quicker proofs. maxActionsPerUpdate is how many writes one method may make.
Write the contract
The contract holds one piece of on-chain state, the commitment, and binds the offchain state to itself:
export class NumberStorageContract extends SmartContract {
@state(OffchainState.Commitments) offchainStateCommitments =
offchainState.emptyCommitments();
offchainState = offchainState.init(this);
Add a method that writes a new entry. update takes the value it expects to find. Passing undefined as from requires that the entry is empty, so this method cannot overwrite an entry that already holds a value:
@method async setNumber(index: Field, value: Field) {
this.offchainState.fields.numbers.update(index, {
from: undefined,
to: value,
});
const total = await this.offchainState.fields.total.get();
this.offchainState.fields.total.update({
from: total,
to: total.orElse(0n).add(1),
});
}
Add a method that replaces a value, where the caller names the value being replaced:
@method async updateNumber(index: Field, from: Field, to: Field) {
this.offchainState.fields.numbers.update(index, { from, to });
}
Finally, add the method that settles. It takes the proof and hands it to the offchain state:
@method async settle(proof: StateProof) {
await this.offchainState.settle(proof);
}
}
Run it
Bind the state to the contract instance, then compile both the offchain state program and the contract:
const contract = new NumberStorageContract(zkAppAddress);
const state = contract.offchainState;
state.setContractInstance(contract);
await offchainState.compile();
await NumberStorageContract.compile();
Write a value. At this point the write is an action, and reading the entry returns none:
const writeTx = await Mina.transaction(sender, async () => {
await contract.setNumber(Field(1), Field(42));
});
await writeTx.prove();
await writeTx.sign([senderKey]).send();
const before = await state.fields.numbers.get(Field(1));
before.isSome.toBoolean(); // false
Settle, and the value becomes readable:
const proof = await state.createSettlementProof();
const settleTx = await Mina.transaction(sender, async () => {
await contract.settle(proof);
});
await settleTx.prove();
await settleTx.sign([senderKey]).send();
const after = await state.fields.numbers.get(Field(1));
after.value.toString(); // '42'
Running npm start in the example prints the whole sequence:
writing 42 at index 1...
before settling, index 1 is: not set yet
settling...
after settling, index 1 is: 42
entries written: 1
updating index 1 from 42 to 43...
index 1 is now: 43
What happens to a stale write
update is the reason to prefer this API over keeping a tree yourself. Two users who read the same value and both write are not a lost update: the settlement applies the first and drops the second, because the second names a value that is no longer there.
The example asserts this. After index 1 has moved from 42 to 43, an update that still expects 42 changes nothing:
await contract.updateNumber(Field(1), Field(42), Field(99));
await settle();
(await state.fields.numbers.get(Field(1))).value.toString(); // still '43'
overwrite is the other option, and it does exactly what its name says: it ignores the previous value. Use it only where a lost update does not matter.
Conclusion
You have stored more state than a zkApp account can hold, with one commitment on chain, and you have seen why a write becomes readable only after a settlement proof.
Check out Tutorial 7: Oracles to learn how to bring data from the outside world into a zkApp.