Arkiv Best Practices & Practical Examples
Arkiv is a decentralized data layer that brings queryable, time-scoped storage to Ethereum. It lets developers store, query, and manage data with built-in expiration and attribute systems. Think of it as an Ethereum-native database where every record (called an entity) has a payload, typed attributes for querying, and a programmable lifespan.
Architecture Overview
Arkiv is designed as three layers:
- Ethereum Mainnet — Final settlement, proof verification, source of truth.
- Arkiv Coordination Layer — Data management, registry, cross-chain sync.
- Specialized DB-Chains — High-performance CRUD via JSON-RPC, indexed queries, programmable expiration.
Nothing in the current source repos confirms the coordination layer or mainnet settlement is live on Tiramisu — treat the testnet as the active layer for now.
Core Concepts
Entities
An entity is a data record containing:
- Payload — The actual data (JSON, text, binary)
- Attributes — Key-value pairs for querying, with explicit types
- Expiry — Automatic expiration via the
expiresparameter (useExpirationTimehelpers) - Content Type — MIME type of the payload
Attributes
Attributes are the backbone of querying. In SDK 0.8, attributes are passed as an object keyed by name (not an array):
import { i32, u64, dec, str } from "@arkiv-network/sdk/attr"
// Object form — each key is the attribute name
attributes: {
type: "note", // bare string → str
priority: i32(5), // explicit i32 for range queries
score: dec("4.5"), // genuine decimal
created: u64(Date.now()), // timestamp as i32
}
When reading, entity.attributes is also an object keyed by name. Each value is { type, value }:
entity.attributes.priority?.value // 5
entity.attributes.priority?.type // "i32"
Important: If you store a number as a string (type: "5"), you lose the ability to do range queries with gt(), lt(), etc. Always use the correct typed helper for attributes you plan to filter by range.
Important: A bare float like 19.99 throws InvalidValueError. Use dec("19.99") for genuine decimals, or scale to an integer (1999 cents) if you only need integer range queries.
Attribute name rules: 1–32 bytes, start with a letter, chars [A-Za-z0-9._-], no $ prefix, no --, not a reserved word (and, or, not, true, false, startswith, exists, typeof, or any type tag). Max 32 attributes per operation.
Prefix matching on string attributes is available via startsWith() in the TypeScript SDK and STARTSWITH in the raw JSON-RPC API (see references/api-reference.md).
Expiry
Every entity has a lifespan expressed via the expires parameter. Always use the ExpirationTime helper — never hardcode raw numbers:
import { ExpirationTime } from "@arkiv-network/sdk"
ExpirationTime.fromMinutes(30)
ExpirationTime.fromHours(1)
ExpirationTime.fromHours(12)
ExpirationTime.fromDays(7)
ExpirationTime.fromWeeks(2)
ExpirationTime.fromMonths(3)
ExpirationTime.permanent()
ExpirationTime.atBlock(1_200_000n)
ExpirationTime.atDate(new Date("2027-01-01"))
Entities can be extended before they expire using extendEntity(). Over-allocating expiration wastes storage fees — start short and extend if needed.
SDK Setup
Arkiv provides a TypeScript SDK. For detailed SDK reference, read references/sdk-reference.md.
TypeScript (Node.js / Bun)
npm install @arkiv-network/sdk viem
# or
pnpm add @arkiv-network/sdk viem
# or
bun add @arkiv-network/sdk viem
The SDK builds on viem and declares it as a peer dependency. Install viem alongside the SDK. The SDK no longer re-exports viem's internals — http, custom, privateKeyToAccount, Hex, etc. must be imported from viem / viem/accounts directly.
Install the @dev tag — npm latest is still 0.7.0, which targets the retired Braga network. All examples below assume 0.8.0-dev.3+.
Two client types exist:
- WalletClient (read/write) — Requires a private key. Use for creating, patching, deleting entities.
- PublicClient (read-only) — No private key needed. Use for queries and event watching.
import { createWalletClient, createPublicClient } from "@arkiv-network/sdk"
import { tiramisu } from "@arkiv-network/sdk/chains"
import { http } from "viem"
import { privateKeyToAccount } from "viem/accounts"
const rpcUrl = process.env.TIRAMISU_RPC_URL
// Register an API key at https://hub.arkiv.network/api-keys
// and include it in the URL: https://rpc.tiramisu.db-chain.testnet.arkiv.network/<key>
const walletClient = createWalletClient({
chain: tiramisu,
transport: http(rpcUrl),
account: privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`),
})
const publicClient = createPublicClient({
chain: tiramisu,
transport: http(rpcUrl),
})
Tiramisu is the current Arkiv testnet. If you are upgrading from Braga, read references/migration-guide.md before editing code.
Wallet Actions
The SDK exposes six core mutation methods:
| Action | What it does |
|---|---|
createEntity() |
Creates a new entity with a payload, content type, attributes, and an expiry |
patchEntity() |
Sets or unsets individual attributes, or replaces the payload, leaving anything unnamed untouched |
deleteEntity() |
Deletes an entity |
extendEntity() |
Moves an entity's expiry later |
changeOwnership() |
Transfers an entity to a new owner |
executeBatch() |
Applies a combination of creates, patches, deletes, extensions, and ownership changes atomically in one transaction |
CRUD Operations
Create
import { jsonToPayload, ExpirationTime } from "@arkiv-network/sdk"
import { i32 } from "@arkiv-network/sdk/attr"
const { entityKey, txHash } = await walletClient.createEntity({
payload: jsonToPayload({ title: "My Note", content: "Hello Arkiv!" }),
contentType: "application/json",
attributes: {
project: "myapp-acme-7x9k",
type: "note",
id: crypto.randomUUID(),
created: i32(Date.now()),
},
expires: ExpirationTime.fromHours(12),
})
Read / Query
Start a query with select(), declaring which entity fields you want returned. Every field is opt-in — including key — and only the selected fields are fetched over the network:
import { eq, gt } from "@arkiv-network/sdk/query"
import { i32 } from "@arkiv-network/sdk/attr"
const result = await publicClient
.select({ key: true, payload: true, attributes: true })
.where(eq("type", "note"), gt("created", i32(Date.now() - 86400000)))
.limit(10)
.fetch()
console.log("Found entities:", result.entities)
if (result.hasNextPage()) {
await result.next()
}
const entity = await publicClient.getEntity(entityKey)
Available fields: key, owner, creator, createdAt, updatedAt, expiresAt, creationFlags, contentType, payload, attributeSchema, attributes.
Creation flags
Set at entity creation only (creationFlags on create). Two flags matter in practice:
readonly— payload and attributes cannot be changed, even by the owner (only delete remains).permissionlessExtension— any address can callextendEntity()on this entity, not just the owner.
Pass the selection inline. A selection stored in a variable widens true to boolean and the result type can't be narrowed; if you must reuse one, annotate it as const.
.where() accepts conditions as varargs, an array, or chained calls — all combined with AND. For nested logic, combine predicates with and() / or() from @arkiv-network/sdk/query:
import { and, or, eq, gt } from "@arkiv-network/sdk/query"
import { i32 } from "@arkiv-network/sdk/attr"
await publicClient
.select({ key: true, payload: true })
.where(eq("type", "note"), or(gt("priority", i32(3)), eq("pinned", "true")))
.fetch()
Patch
patchEntity is a partial update — only fields named in set, unset, payload, or contentType are touched:
await walletClient.patchEntity({
entityKey,
set: { status: "done", updated: i32(Date.now()) },
unset: ["draft"],
})
Entity key, owner, creation flags, and expiry never change via patch. Use extendEntity() for expiry and changeOwnership() for ownership.
Delete
await walletClient.deleteEntity({ entityKey })
Extend Expiration
await walletClient.extendEntity({
entityKey,
expires: ExpirationTime.fromHours(1),
})
Change Ownership
await walletClient.changeOwnership({
entityKey,
newOwner: "0xNewOwnerAddress",
})
Best Practices
1. Always Use a Project Attribute
All entities in Arkiv are public and stored in a shared database. Every project must define a unique project attribute and include it on every entity. This is how you distinguish your app's data from everyone else's.
Create a dedicated file (e.g., lib/arkiv.ts) that exports the attribute name and value as separate constants:
/** Attribute name used to filter this project's entities. */
export const PROJECT_ATTRIBUTE_NAME = "project" as const
/** Globally unique value identifying this project. */
export const PROJECT_ATTRIBUTE_VALUE = "myapp-acme-7x9k" as const
if (!PROJECT_ATTRIBUTE_VALUE) {
throw new Error(
"Set PROJECT_ATTRIBUTE_VALUE to a unique string that identifies your project.",
)
}
When creating this file, come up with a globally unique value — for example, a combination of your project name, organization, and a short random suffix.
Include the project attribute in every create/patch and every query:
import { PROJECT_ATTRIBUTE_NAME, PROJECT_ATTRIBUTE_VALUE } from "@/lib/arkiv"
const { entityKey, txHash } = await walletClient.createEntity({
payload: jsonToPayload({ title, content }),
contentType: "application/json",
attributes: {
[PROJECT_ATTRIBUTE_NAME]: PROJECT_ATTRIBUTE_VALUE,
entityType: "post",
},
expires: ExpirationTime.fromDays(30),
})
const result = await publicClient
.select({ key: true, payload: true })
.where(
eq(PROJECT_ATTRIBUTE_NAME, PROJECT_ATTRIBUTE_VALUE),
eq("entityType", "post"),
)
.limit(50)
.fetch()
Without this, your queries will return data from other projects, and other projects will see yours. This is the single most important practice for any Arkiv project.
2. Register an RPC API Key
Anonymous RPC access to Tiramisu is rate-limited. Register a project at https://hub.arkiv.network/api-keys for elevated rate limits (1,000,000 units/month). Pass the key as a URL path segment, X-API-KEY header, or Authorization: Bearer header:
const rpcUrl = `https://rpc.tiramisu.db-chain.testnet.arkiv.network/${process.env.TIRAMISU_API_KEY}`
const publicClient = createPublicClient({
chain: tiramisu,
transport: http(rpcUrl),
})
3. Separate Read and Write Clients
Always use createPublicClient for queries. It prevents accidental writes, doesn't require a private key, and is safe for frontend/public use. Use createWalletClient only for write paths — backend services with a server-held key, or client-side via the user's injected wallet (MetaMask/wagmi).
4. Design Attributes for Queryability
Think about how you'll query data when you choose attributes. Attributes are your indexes — without the right ones, you'll be fetching too much data and filtering client-side.
attributes: {
type: "vote",
proposalKey: proposalId,
voter: voterAddr,
choice: "yes",
weight: i32(1),
}
5. Use Batch Operations — and Never Parallelize Writes from One Wallet
Every write is an on-chain transaction, and all transactions from one wallet must use strictly sequential nonces. The SDK does not manage nonces for you — two writes in flight at the same moment fetch the same nonce and collide.
// Bad — sequential, slow and expensive
for (const item of items) {
await walletClient.createEntity(item)
}
// Also bad — parallel writes from one wallet collide on the transaction nonce
await Promise.all(items.map((item) => walletClient.createEntity(item)))
// Good — single batch operation, single transaction, one nonce
await walletClient.executeBatch({
creates: items.map((item) => ({
payload: jsonToPayload(item.data),
contentType: "application/json",
attributes: item.attributes,
expires: ExpirationTime.fromHours(1),
})),
})
executeBatch() accepts creates, patches, deletes, extensions, and ownershipChanges, and you can mix them in one call. If separate concurrent transactions are unavoidable, create the account with viem's nonceManager (privateKeyToAccount(key, { nonceManager }) from viem/accounts) so nonces are allocated locally — but note this only coordinates writes within a single process.
6. Write Specific Queries
Broad queries return too much data and cost more. Always add multiple filter criteria:
await publicClient
.select({ key: true })
.where(
eq("type", "note"),
gt("created", i32(Date.now() - 86400000)),
gt("priority", i32(3)),
)
.fetch()
The same thinking applies to field selection: select() fetches only the fields you name, so ask for what you'll actually use.
7. Right-Size Expiration
Match expires to actual data lifetime. Session data gets 30 minutes, not 7 days. Cache gets 1 hour. Don't over-allocate — it costs more and pollutes queries with stale data before cleanup.
8. Never Expose Private Keys
const privateKey = process.env.PRIVATE_KEY
// Never hardcode: const privateKey = "0x1234..." // DANGEROUS
9. Use Typed Attributes for Typed Data
Use the typed helpers from @arkiv-network/sdk/attr for attributes you'll filter or sort by:
import { i32, dec, u64 } from "@arkiv-network/sdk/attr"
attributes: {
priceCents: i32(1999), // integer range queries
rating: dec("4.5"), // genuine decimal
blockHeight: u64(1_200_000n), // large unsigned integer
}
String attributes only support equality and prefix matching. Numeric attributes support all comparison operators.
10. Model Related Data with Shared Attributes
Link entities together using a shared attribute key (like proposalKey in a voting system). This is Arkiv's version of foreign keys:
// Proposal entity
attributes: { type: "proposal" }
// Vote entities reference the proposal
attributes: {
type: "vote",
proposalKey: proposalEntityKey,
}
// Query all votes for a proposal
await publicClient
.select({ key: true, payload: true })
.where(eq("type", "vote"), eq("proposalKey", proposalEntityKey))
.fetch()
11. Understand $owner vs $creator
Every Arkiv entity has two special metadata fields:
- $owner — The wallet address that currently owns the entity. The owner has permission to patch, delete, and extend the entity. Ownership can be transferred, so the owner may change over an entity's lifetime.
- $creator — The wallet address that originally created the entity. This is set at creation time and is immutable — it can never change. Being the creator does not grant any special privileges (only the owner can modify/delete).
Query these with .ownedBy() and .createdBy(), or include them in results by selecting the owner / creator fields:
const owned = await publicClient
.select({ key: true, owner: true, payload: true })
.where(eq(PROJECT_ATTRIBUTE_NAME, PROJECT_ATTRIBUTE_VALUE))
.ownedBy("0xOwnerAddress")
.fetch()
const created = await publicClient
.select({ key: true, creator: true, payload: true })
.where(eq(PROJECT_ATTRIBUTE_NAME, PROJECT_ATTRIBUTE_VALUE))
.createdBy("0xCreatorAddress")
.fetch()
When to use which:
- Use $creator (
createdBy) when you need a tamper-proof guarantee of who originally wrote the data. Since it's immutable, it cannot be spoofed after creation. - Use $owner (
ownedBy) when you need to know who currently controls the entity. Be aware that ownership can change.
12. Filter by Creator Wallet for Trusted Data
When your app has a backend that publishes data to Arkiv and a frontend that reads it, filtering by PROJECT_ATTRIBUTE alone is not enough. A malicious actor can create entities with your project attribute to inject fake data.
Combine PROJECT_ATTRIBUTE filtering with .createdBy() to only accept entities created by your trusted backend wallet:
export const CREATOR_WALLET_ADDRESS = "0xYourBackendWalletAddress"
const trustedPosts = await publicClient
.select({ key: true, payload: true })
.where(
eq(PROJECT_ATTRIBUTE_NAME, PROJECT_ATTRIBUTE_VALUE),
eq("entityType", "post"),
)
.createdBy(CREATOR_WALLET_ADDRESS)
.fetch()
This works because $creator is immutable — no one can create an entity and fake the creator address.
13. Handle Errors Gracefully
The Arkiv SDK does not retry on failure — all methods throw on error. Write operations can fail for several reasons: the user rejects the transaction in MetaMask, the wallet has insufficient gas, the RPC endpoint is unreachable, or the entity has already expired. Wrap write operations in try/catch:
import {
InvalidValueError,
InvalidExpiryError,
EmptyPatchError,
ConflictingMutationError,
NoEntityFoundError,
} from "@arkiv-network/sdk"
try {
await walletClient.createEntity({ /* ... */ })
} catch (error) {
if (error instanceof InvalidValueError) {
// a value did not match its declared type (e.g. bare float)
} else if (error instanceof InvalidExpiryError) {
// invalid expiry configuration
} else if (error instanceof EmptyPatchError) {
// patchEntity called with no changes
} else if (error instanceof ConflictingMutationError) {
// same attribute in both set and unset
} else {
throw error
}
}
Read operations can also throw on network errors. If your app needs retries, implement them yourself — the SDK won't do it for you.
14. Validate Entity Data and Model Relationships
Two important advanced patterns for production Arkiv apps:
- Schema validation —
entity.toJson()returnsany. Always validate with a schema library (zod, valibot, etc.) to protect against malformed payloads and namespace collisions. - Relationship entities — Arkiv attributes are flat key-value pairs with no array type. To model one-to-many or many-to-many relationships (tags, skills, memberships), create separate relationship entities instead of encoding lists into attributes.
For full examples and code for both patterns, read references/advanced-patterns.md.
15. Remember That patchEntity Is a Partial Update
patchEntity only touches what you name — attributes in set are added/updated, names in unset are removed, and anything not mentioned stays as-is. Entity key, owner, creation flags, and expiry never change via patch:
// Good — only changes status, leaves everything else untouched
await walletClient.patchEntity({
entityKey,
set: { status: "done" },
unset: ["draft"],
})
// Bad — throws EmptyPatchError
await walletClient.patchEntity({ entityKey })
// Bad — throws ConflictingMutationError
await walletClient.patchEntity({
entityKey,
set: { status: "done" },
unset: ["status"],
})
To replace the payload without touching attributes:
await walletClient.patchEntity({
entityKey,
payload: jsonToPayload({ title: "Updated", content: "New content" }),
contentType: "application/json",
})
16. Sort Client-Side — Server Ordering Is Not Supported
The network always returns matching entities newest first; server-side ordering by anything else is not supported. orderBy(), asc(), and desc() were removed in SDK 0.8. To sort by an attribute, fetch the entities and sort in JavaScript:
const { entities } = await publicClient
.select({ key: true, payload: true, attributes: true })
.where(eq("type", "note"))
.fetch()
const priorityOf = (entity: (typeof entities)[number]) =>
Number(entity.attributes.priority?.value ?? 0)
entities.sort((a, b) => priorityOf(b) - priorityOf(a))
Caution: .limit(n) caps results before your sort — it gives you the n newest matches, not the top n by your attribute. To get a true top n, fetch all matching entities (paginating if needed), then sort and slice.
Upgrading to SDK 0.8.0
When a project upgrades @arkiv-network/sdk from 0.7.x to 0.8.0-dev.3+, apply all of these together:
- Install the latest release:
npm install @arkiv-network/sdk viem. - Swap chain:
braga→tiramisuin all imports and client setup. - Fix imports: import
ExpirationTime,jsonToPayload,stringToPayloadfrom@arkiv-network/sdkroot (not/utils). - Attributes array → object:
{ key: "type", value: "note" }→{ type: "note" }. expiresIn→expires: rename the parameter on create/extend/batch.updateEntity→patchEntity: partial updates withset/unsetinstead of full replace.mutateEntities→executeBatch: rename and usepatchesinstead ofupdates.subscribeEntityEvents→watchEntityEvents: sync, returns unwatch function directly.- Error renames:
InvalidAttributeError→InvalidValueError,InvalidExpirationError→InvalidExpiryError. - Remove
orderBy()/asc()/desc(): sort client-side. - Remove
payloadToString: useentity.toText()/entity.toJson(). - Use typed attribute helpers: import from
@arkiv-network/sdk/attrfor non-default types.
For the full migration checklist including network constants, read references/migration-guide.md.
Migration from Braga to Tiramisu
Braga was retired on 12 August 2026. When a user wants to upgrade an existing Arkiv project, treat it as a migration instead of a generic refactor. The SDK API has breaking changes from 0.7 to 0.8, and the main work is swapping the target chain, updating wallet/network config, renaming Braga-specific env vars, and recreating testnet data.
Follow this sequence:
- Read
references/migration-guide.mdbefore making edits. - Install
@arkiv-network/sdkand apply the "Upgrading to SDK 0.8.0" checklist above. - Replace
bragachain imports/usages withtiramisu. - Update RPC URLs, WebSocket URLs, chain IDs, explorer links, faucet links, and wallet
nativeCurrency(symbolGLM). - Register an RPC API key at
https://hub.arkiv.network/api-keys. - Rename env vars and config keys so
BRAGA_*names do not remain in active codepaths. - Re-seed or recreate any entities the app expects on startup, because Braga state does not migrate to Tiramisu.
Keep Braga only as legacy context during migration work. For new code, examples, and setup instructions, default to Tiramisu.
Reference Files
The references/ directory contains detailed documentation for specific topics. Read these when you need deeper information:
references/sdk-reference.md— Full SDK API surface: all WalletClient/PublicClient methods, theselect()query builder API, typed attributes, validation rules, nonce management, ExpirationTime helpers, payload utilities,watchEntityEvents, MetaMask browser usage, and CDN imports.references/integration-patterns.md— Four integration scenarios: backend read/write (Next.js/Express), client-side reading (TanStack Query hooks), client-side writing (MetaMask and wagmi/RainbowKit), and live events with cache invalidation.references/api-reference.md— Raw JSON-RPC 2.0 API:arkiv_querysyntax, typed literals, query operators, synthetic attributes, pagination with cursors, and utility methods.references/advanced-patterns.md— Advanced data modeling: schema validation with zod/valibot, and modeling lists with relationship entities.references/migration-guide.md— Step-by-step Braga to Tiramisu migration checklist: chain swaps, SDK 0.7→0.8 API changes, env/config updates, faucet, and reseeding testnet data.
Testnet Resources
| Resource | URL |
|---|---|
| Chain ID | 7738577 / 0x7614d1 |
| HTTP RPC | https://rpc.tiramisu.db-chain.testnet.arkiv.network |
| WebSocket RPC | wss://rpc.tiramisu.db-chain.testnet.arkiv.network |
| Block explorer | https://indexer.tiramisu.db-chain.testnet.arkiv.network |
| Faucet | https://hub.arkiv.network/faucet (0.1 GLM, 24h cooldown, wallet + SIWE) |
| API keys | https://hub.arkiv.network/api-keys |
Troubleshooting
- "Invalid sender" — Your RPC URL may point to the wrong network. Verify it matches Tiramisu.
- "Insufficient funds" — Get test GLM from the Tiramisu faucet. Writes require gas.
- Queries return empty — Check that attributes match exactly (case-sensitive). Verify entities haven't expired.
InvalidValueErroron a number — Bare floats are rejected. Usedec("19.99")for decimals ori32(1999)for scaled integers.NoEntityFoundError— The entity does not exist or has expired. Expiration fires no event in 0.8 — poll or query by$expiresAt.- RPC rate limited — Register an API key at
https://hub.arkiv.network/api-keysand pass it in the RPC URL or as a header. - Query parse error on
ne()/exists()/hasType()— These operators are exported by the SDK but not implemented on the node. For inequality, usenot(eq(...))as the not-equal workaround (matches entities that never set the attribute). There is no substitute forexists()orhasType()today.