Documentation
Guides and reference for OutLayer verifiable compute and agent custody.
Docs navigation
Persistent Storage
OutLayer provides encrypted persistent storage for your WASM projects. Data survives across executions and version updates, with automatic user isolation and atomic operations for concurrent-safe updates. Beside the encrypted functions, the raw functions store bytes your module has sealed itself with an encryption key.
Requires Projects: Storage is only available for code deployed as a Project. You must use WASI Preview 2 (wasm32-wasip2) build target.
Overview#
All versions of a project share the same storage namespace. Data written by v1 is readable by v2. Storage is encrypted using the keystore TEE and isolated per user.
Encrypted
set/get encrypt with project-specific keys in the TEE; raw records hold bytes your module sealed
User-Isolated
Each user has their own namespace, automatic isolation
Atomic Operations
Increment, decrement, compare-and-swap for concurrency
Quick Start#
Add the OutLayer SDK to your Cargo.toml:
[dependencies]
outlayer = "0.2"use outlayer::storage;
fn main() {
// Store data
storage::set("counter", &42i64.to_le_bytes()).unwrap();
// Read data
if let Some(data) = storage::get("counter").unwrap() {
let value = i64::from_le_bytes(data.try_into().unwrap());
println!("Counter: {}", value);
}
// Atomic increment
let new_value = storage::increment("visits", 1).unwrap();
println!("Visit count: {}", new_value);
}Note: Storage automatically uses your project context. When you callrequest_execution(Project { project_id }), the contract passes the project UUID to the worker, which uses it for storage namespace and encryption.
Storage API#
The storage interface is defined in worker/wit/deps/storage.wit and imported as near:storage/[email protected]:
interface api {
// Basic operations
set: func(key: string, value: list<u8>) -> string;
get: func(key: string) -> tuple<list<u8>, string>;
has: func(key: string) -> tuple<bool, string>;
delete: func(key: string) -> tuple<bool, string>;
list-keys: func(prefix: string) -> tuple<string, string>;
// Conditional writes (atomic operations)
set-if-absent: func(key: string, value: list<u8>) -> tuple<bool, string>;
set-if-equals: func(key: string, expected: list<u8>, new-value: list<u8>) -> tuple<bool, list<u8>, string>;
// Raw storage: bytes as given, no keystore on the path (see Raw Storage)
set-raw: func(key: string, value: list<u8>) -> string;
get-raw: func(key: string) -> tuple<list<u8>, string>;
set-if-absent-raw: func(key: string, value: list<u8>) -> tuple<bool, string>;
set-if-equals-raw: func(key: string, expected: list<u8>, new-value: list<u8>) -> tuple<bool, list<u8>, string>;
// Atomic counters (encrypted records only)
increment: func(key: string, delta: s64) -> tuple<s64, string>;
decrement: func(key: string, delta: s64) -> tuple<s64, string>;
// Worker storage (with public option for cross-project reads)
// is-encrypted: true (default) = encrypted, false = plaintext (public)
set-worker: func(key: string, value: list<u8>, is-encrypted: option<bool>) -> string;
// project: none = current project; some(project) = another project's PUBLIC data,
// named "owner.near/project-name" or by its uuid "p0000000000000001"
get-worker: func(key: string, project: option<string>) -> tuple<list<u8>, string>;
// Version migration
get-by-version: func(key: string, wasm-hash: string) -> tuple<list<u8>, string>;
// Cleanup
clear-all: func() -> string;
clear-version: func(wasm-hash: string) -> string;
}Methods Reference#
| Method | Description | Returns |
|---|---|---|
| set(key, value) | Store a key-value pair | Error string (empty on success) |
| get(key) | Retrieve value by key | (data, error) |
| has(key) | Check if key exists | (exists, error) |
| delete(key) | Delete a key | (deleted, error): deleted if it existed |
| list-keys(prefix) | List keys with prefix | (JSON array string, error) |
| set-if-absent(key, value) | Set only if key doesn't exist | (inserted: bool, error) |
| set-if-equals(key, expected, new) | Compare-and-swap (atomic update) | (success, current, error) |
| set-raw(key, value) | Store bytes as given (raw record) | Error string (empty on success) |
| get-raw(key) | Read the bytes of a raw record | (data, error) |
| set-if-absent-raw(key, value) | Store bytes only if the key holds no record, in either mode | (inserted: bool, error) |
| set-if-equals-raw(key, expected, new) | Compare-and-swap on the stored bytes | (success, current, error) |
| increment(key, delta) | Atomic increment (i64) | (new_value: i64, error) |
| decrement(key, delta) | Atomic decrement (i64) | (new_value: i64, error) |
| set-worker(key, value, is_encrypted) | Store worker data (public if is_encrypted=false) | Error string |
| get-worker(key, project) | Get worker data (another project's public data if project names one, by name or uuid) | (data, error) |
Atomic Operations#
Use atomic operations for concurrent-safe updates. These are essential for counters, rate limiters, and any state that multiple executions might modify simultaneously.
Why use atomic operations? Regular set() can cause race conditions when multiple users execute simultaneously. Atomic operations ensure data integrity.
use outlayer::storage;
// ============ set_if_absent ============
// Only inserts if key doesn't exist - perfect for initialization
if storage::set_if_absent("counter", &0i64.to_le_bytes())? {
println!("Counter initialized to 0");
} else {
println!("Counter already exists");
}
// ============ increment / decrement ============
// Atomic counters - handles concurrent updates automatically
let views = storage::increment("page_views", 1)?;
println!("Page views: {}", views);
let stock = storage::decrement("stock:item_123", 1)?;
if stock < 0 {
println!("Out of stock!");
}
// ============ set_if_equals (Compare-and-Swap) ============
// Update only if current value matches expected
let current = storage::get("balance")?.unwrap_or(vec![0; 8]);
let balance = i64::from_le_bytes(current.clone().try_into().unwrap());
let new_balance = balance + 100;
match storage::set_if_equals("balance", ¤t, &new_balance.to_le_bytes())? {
(true, _) => println!("Balance updated!"),
(false, Some(actual)) => println!("Concurrent update! Retry with {:?}", actual),
(false, None) => println!("Key was deleted"),
}Important: increment/decrement expect values stored as 8-byte little-endian i64. If you store counters differently (e.g., as string), use set_if_equals instead.
Raw Storage#
set-raw, get-raw, set-if-absent-raw and set-if-equals-raw store the bytes as given, in the same storage as set/get — the same account's cell, the same project, the same key namespace — and make no keystore call.
The operator can read raw records. The key name and the value are stored as given. Encrypt the value first, with an encryption key, and keep secrets out of key names.
- One key, one record, one mode.
get-rawon a record written withset, orgeton one written withset-raw, is an error; so is a write in the other mode. No write converts a record from one mode to the other: delete it first. has,deleteandlist-keyswork on records of both modes.incrementanddecrementwork on encrypted records only; on a raw record they are an error.set-if-absent-rawinserts only when the key holds no record in either mode.set-if-equals-rawreplaces the value only when the stored bytes equalexpectedexactly; on failurecurrentholds the stored bytes (empty when the key holds no record).get-rawanswers an empty value when the key holds no record.
Sealed records#
The pattern the raw functions exist for — records only your module can open, under names only your module can read:
- Storage key:
mac(path, vault, name)in hex, notname. - Value:
encrypt(path, vault, value, aad = name)— a ciphertext moved onto another record then fails to open. - Reads and writes: the raw functions. A compare-and-swap compares the stored ciphertext bytes: pass the ciphertext you read as
expected.
The Rust SDK provides helpers for this pattern. A complete module built on the host functions directly: Encryption Keys — Sealed storage.
User Data Isolation#
Storage is automatically isolated per user at the protocol level. Each user has their own namespace:
- Automatic isolation:
alice.nearcannot read data stored bybob.near - Per-user encryption: Different encryption keys for each user's data
- Transparent to WASM: Your code uses simple keys like
balance- the platform handles namespacing - Caller-triggered access: WASM can only read user data when that user triggers the execution
// alice.near calls execution:
storage::set("balance", b"100");
// Database key: project_uuid:alice.near:balance = "100"
// bob.near calls execution:
storage::set("balance", b"200");
// Database key: project_uuid:bob.near:balance = "200"
// alice.near reads:
storage::get("balance") // -> "100" (her data)
// bob.near reads:
storage::get("balance") // -> "200" (his data)
// WASM code CANNOT read another user's data!Whose cell: storage_account#
Every storage function outside the -worker ones reads and writes one account's cell of the project. The worker fixes that account before the run starts, from the project's manifest and the job. Your code never names an account, and no storage function takes one. The manifest field storage_account picks which account it is:
| Value | On chain | Over HTTPS |
|---|---|---|
| "signer" (default) | the transaction's signer | the payment key's owner |
| "predecessor" | the account that called the contract — a relaying contract, not the user who signed | the payment key's owner |
{
"storage_account": "predecessor"
}- A run that carries no predecessor, under
"predecessor", is refused before it executes — it never falls back to the signer's cell. - Any other value makes the manifest unreadable, and the run is refused.
@workerstorage (set-worker,get-worker) is the project's own and is not affected.- A module that seals records under a
caller: "predecessor"encryption key declaresstorage_account: "predecessor"too, so the key and the cell belong to the same account.
Worker Storage (Shared State)#
Worker-private storage uses @worker as account_id, making it shared across all users. Users cannot directly access this storage - only your WASM code can read/write it.
// Any user calls execution:
storage::set_worker("total_count", b"100");
// Database key: project_uuid:@worker:total_count = "100"
// Any other user calls:
storage::get_worker("total_count") // -> "100" (same shared data!)
// Use case: Private Token balances
storage::set_worker("balances", balances_json); // Shared across all users
// Users cannot directly read balances - only through your WASM methodsUse case: Worker storage is ideal for shared state like token balances, global counters, or application configuration. Users interact with this data only through your WASM methods - they cannot bypass your logic.
Public Storage (Cross-Project Reads)#
Public storage is unencrypted worker storage that can be read by other projects. This enables use cases like shared oracle price feeds, public configuration, or cross-project data sharing.
Important: Public storage is NOT encrypted. Only store data you want to be readable by other projects and external HTTP clients. Use regular worker storage for private data.
use outlayer::storage;
// Store PUBLIC data (is_encrypted = false)
storage::set_worker_with_options(
"oracle:ETH",
price_json.as_bytes(),
Some(false) // <-- Makes it public!
)?;
// Read from current project (works for both public and private)
let data = storage::get_worker("oracle:ETH")?;
// Read PUBLIC data from ANOTHER project, by its name...
let price = storage::get_worker_from_project(
"oracle:ETH",
Some("oracle.near/price-feed")
)?;
// ...or by its uuid (OUTLAYER_PROJECT_UUID in that project's runs)
let price = storage::get_worker_from_project("oracle:ETH", Some("p0000000000000001"))?;
// Ok(None): no such key, or no such project
// Err: the key is stored encrypted, or the project is in neither formExternal HTTP API#
Public storage can also be read by external clients via HTTP:
# JSON format (default) - value is base64-encoded
curl "https://api.outlayer.ai/public/storage/get?project=oracle.near/price-feed&key=oracle:ETH"
# {"exists":true,"value":"eyJwcmljZSI6IjM1MDAuMDAifQ=="}
# The same project by uuid; raw format returns the bytes directly
curl "https://api.outlayer.ai/public/storage/get?project=p0000000000000001&key=oracle:ETH&format=raw"
# {"price":"3500.00"}
# Up to 50 keys at once
curl -X POST https://api.outlayer.ai/public/storage/batch -H "Content-Type: application/json" -d '{"project":"oracle.near/price-feed","keys":["oracle:ETH","oracle:BTC"]}'
# {"results":{"oracle:ETH":{"exists":true,"value":"..."},"oracle:BTC":{"exists":false}}}projecttakes the project's name (owner.near/project-name) or its uuid;project_uuidis accepted as an alias, in either form.- An unknown project reads like a missing key:
{"exists":false}(404 withformat=raw). - An encrypted key is denied: 403 from
/public/storage/get; in a batch, that key answers{"exists":true,"error":"encrypted"}. - A
projectin neither form is a 400 naming both.
Oracle Price Feeds
Share price data across projects without API calls
Public Configuration
Share settings that other projects can read
Note: Encrypted (default) worker data is NOT accessible via cross-project reads. Only data stored with is_encrypted=false can be read by other projects.
Security#
- Records written with
set, the conditional writes and the counters are encrypted by the keystore TEE before storage - Raw records (
set-rawand the other-rawfunctions) are stored as given: encrypt them first - Encryption key derived from:
storage:{project_uuid}:{account_id} - Worker-private storage uses
@workeras account_id - Data is automatically deleted when project is deleted
Warning: Deleting a project permanently removes all storage data. This action cannot be undone. Export any important data before deletion.
Use Cases#
User Preferences
Store settings, themes, language preferences per user
Counters & Analytics
Page views, API calls, usage metrics with atomic increments
Caching
Cache expensive computation results for subsequent calls
Session Data
Store temporary state between executions
Private Tokens
Use worker storage for token balances users cannot directly access
Rate Limiting
Track API calls per user with atomic counters
Oracle Price Feeds
Share public data across projects (set is_encrypted=false)
Distributed Locks
Use set_if_absent for implementing locks
Best Practices#
- Use WASI Preview 2 (wasm32-wasip2) build target
- Use key prefixes for organization (e.g.,
user:alice:settings) - Use
increment/decrementfor counters instead of get+set - Use
set_if_absentfor one-time initialization - Document your key format for future data migrations
- Use
get_by_versionwhen migrating data between versions
Related Documentation
- Projects & Versions - Create and manage projects for storage access
- Building OutLayer App - WASI P1 vs P2, building WASM modules
- Examples - See private-token-ark for storage usage