Skip to content

This guide targets the Tiramisu testnet. Check Networks for current testnets and faucets.

This page collects essential patterns and conventions for reliable, production-ready Arkiv applications. It covers project attributes, data modeling, security, and error handling.

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 that exports this attribute:

lib/arkiv.ts
export const PROJECT_ATTRIBUTE = {
key: "project",
value: "myapp-acme-7x9k", // use a globally unique string
} as const;

Include it in every create/patch call and every query:

import { PROJECT_ATTRIBUTE } from "./lib/arkiv"
// Creating: always include PROJECT_ATTRIBUTE
const { entityKey } = await walletClient.createEntity({
payload: jsonToPayload({ title, content }),
contentType: "application/json",
attributes: { [PROJECT_ATTRIBUTE.key]: PROJECT_ATTRIBUTE.value, entity_type: "post" },
expires: ExpirationTime.fromDays(30),
})
// Querying: always filter by PROJECT_ATTRIBUTE
const result = await publicClient
.select({ key: true, payload: true })
.where(
eq(PROJECT_ATTRIBUTE.key, PROJECT_ATTRIBUTE.value),
eq("entity_type", "post"),
)
.limit(50)
.fetch()

Always use createPublicClient for queries. It prevents accidental writes, does not require a private key, and is safe for frontend use. Reserve createWalletClient for backend services that create, patch, or delete entities.

Attributes are your indexes. Without the right ones, you will fetch too much data and filter client-side.

// Good: attributes map to your query patterns
attributes: {
type: "vote", // filter by entity type
proposal_key: proposalId, // link related entities
voter: voterAddr, // filter by user
choice: "yes", // filter by value
weight: 1, // numeric for aggregation
}

Individual creates in a loop are slow and expensive. Use executeBatch():

// Bad: sequential, slow
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
await walletClient.executeBatch({
creates: items.map((item) => ({
payload: jsonToPayload(item.data),
contentType: "application/json",
attributes: item.attributes,
expires: ExpirationTime.fromHours(1),
})),
})

If you genuinely need separate concurrent transactions, create the account with viem’s nonceManager: see Concurrent Writes and Nonces.

Broad queries return too much data. Always add multiple filter criteria:

// Bad: returns every note ever
await publicClient.select({ key: true }).where(eq("type", "note")).fetch()
// Good: narrows to what you need
await publicClient
.select({ key: true })
.where(
eq("type", "note"),
gt("created", Date.now() - 86400000),
gt("priority", 3),
)
.fetch()

The same applies to the field selection: select() fetches only the fields you name, so ask for what you will actually use. select() with no argument (fetch everything) is convenient while prototyping but wasteful in production.

Match expires to actual data lifetime:

Use CaseDuration
Session dataExpirationTime.fromMinutes(30)
CacheExpirationTime.fromHours(1)
Temp filesExpirationTime.fromHours(24)
Weekly dataExpirationTime.fromDays(7)

Do not over-allocate: it costs more and pollutes queries with stale data.

CAUTION: Do not hardcode a private key in your source code. Anyone who reads the file can spend your funds.

// Always load from environment
const privateKey = process.env.PRIVATE_KEY
// Never hardcode
const privateKey = "0x1234..." // DANGEROUS

Make sure that the length and content are valid before you create an entity:

function createNote(userInput: string) {
if (!userInput || userInput.length > 10000) {
throw new Error("Invalid input")
}
const sanitized = userInput.trim()
return walletClient.createEntity({
payload: stringToPayload(sanitized),
contentType: "text/plain",
attributes: { type: "note" },
expires: ExpirationTime.fromHours(12),
})
}

To filter or sort by a value, store it as a numeric attribute (i32, u64, u256, or dec). String attributes support only eq() and startsWith().

// Good: numeric, supports gt(), lt() operators
{ priority: 5 }
// Bad: string, only eq() and startsWith() work
{ priority: '5' }

A bare number defaults to i32, and i32 values must be integers: a float like 19.99 throws InvalidValueError. Use dec(...) from @arkiv-network/sdk/attr for genuine decimals, or scale to a fixed precision if the value is more like a count:

import { dec } from "@arkiv-network/sdk/attr"
// Good: a real decimal, full precision, still supports range queries
{ price: dec("19.99") }
// Also fine: scaled to cents, integer, still supports range queries
{ priceCents: 1999 }
// Throws InvalidValueError: 19.99 is not a valid i32
{ price: 19.99 }
Section titled “10. Model Related Data with Shared Attributes”

Link entities together with a shared attribute key. This is Arkiv’s version of foreign keys:

// Proposal entity
attributes: { type: "proposal" }
// Vote entities reference the proposal
attributes: {
type: "vote",
proposal_key: proposalEntityKey,
}
// Query all votes for a proposal
await publicClient
.select({ key: true, payload: true })
.where(eq("type", "vote"), eq("proposal_key", proposalEntityKey))
.fetch()
  • $owner: the wallet that currently owns the entity. It can change when the owner transfers the entity. Use .ownedBy() to check who can modify or delete it.
  • $creator: the wallet that first created the entity. This field never changes. Use .createdBy() for tamper-evident source verification.

When your backend publishes data that a frontend reads, filtering by PROJECT_ATTRIBUTE alone is not enough. A malicious actor can create entities with your project attribute.

Combine project filtering with .createdBy():

lib/arkiv.ts
export const PROJECT_ATTRIBUTE = {
key: "project",
value: "myapp-acme-7x9k",
} as const;
export const CREATOR_WALLET_ADDRESS = "0xYourBackendWalletAddress";
import { PROJECT_ATTRIBUTE, CREATOR_WALLET_ADDRESS } from "./lib/arkiv"
const trustedPosts = await publicClient
.select({ key: true, payload: true })
.where(
eq(PROJECT_ATTRIBUTE.key, PROJECT_ATTRIBUTE.value),
eq("entity_type", "post"),
)
.createdBy(CREATOR_WALLET_ADDRESS)
.fetch()

The SDK does not retry on failure. Wrap write operations in try/catch and handle each failure mode:

try {
const { entityKey } = await walletClient.createEntity({ ... })
} catch (error) {
// - User rejected the transaction (MetaMask dismissed)
// - Insufficient funds / gas
// - Network error (RPC unreachable)
// - Entity already deleted or expired (for patch/extend)
console.error("Transaction failed:", error)
}

entity.toJson() returns any. Validate with a schema library to protect against malformed payloads:

import { z } from "zod"
const PostSchema = z.object({
title: z.string(),
content: z.string(),
author: z.string().optional(),
})
type Post = z.infer<typeof PostSchema>
function parsePost(entity: any): Post {
const raw = entity.toJson()
const result = PostSchema.safeParse(raw)
if (!result.success) {
throw new Error("Entity data does not match expected schema")
}
return result.data
}

15. Model Lists with Relationship Entities

Section titled “15. Model Lists with Relationship Entities”

Arkiv attributes are flat key-value pairs: there is no array type. Do not try to encode lists into attributes:

// BAD: cannot query "all profiles with skill frontend"
attributes: {
skills_0: "frontend",
skills_1: "backend",
}
// BAD: cannot query individual skills
attributes: {
skills: "frontend, backend, devops",
}

Instead, create separate relationship entities:

// 1. Create the profile
const { entityKey: profileKey } = await walletClient.createEntity({
payload: jsonToPayload({ name: "Alice", bio: "Full-stack dev" }),
contentType: "application/json",
attributes: {
[PROJECT_ATTRIBUTE.key]: PROJECT_ATTRIBUTE.value,
entity_type: "profile",
profile_id: "alice-123",
},
expires: ExpirationTime.fromDays(30),
})
// 2. One relationship entity per skill
const skills = ["frontend", "backend", "devops"]
await walletClient.executeBatch({
creates: skills.map((skill) => ({
payload: jsonToPayload({ profileId: "alice-123", skill }),
contentType: "application/json",
attributes: {
[PROJECT_ATTRIBUTE.key]: PROJECT_ATTRIBUTE.value,
entity_type: "profileSkill",
profile_id: "alice-123",
skill,
},
expires: ExpirationTime.fromDays(30),
})),
})
// 3. Query all profiles with "frontend" skill
const frontendDevs = await publicClient
.select({ key: true, payload: true })
.where(
eq(PROJECT_ATTRIBUTE.key, PROJECT_ATTRIBUTE.value),
eq("entity_type", "profileSkill"),
eq("skill", "frontend"),
)
.fetch()

16. Remember That patchEntity Only Changes What You Name

Section titled “16. Remember That patchEntity Only Changes What You Name”

patchEntity is a partial update, not a full replace. Attributes you do not name in set or unset keep their value. The payload and the content type stay as they are unless you pass new ones. That makes the common “change one field” case simple:

// Good: only the named attribute changes, everything else is untouched
await walletClient.patchEntity({
entityKey,
set: { status: "done" },
})

For a full replacement, patching is not the right tool. It never removes an attribute you do not name in unset. To get a new payload and a clean attribute set, delete the entity and create it again:

// Full replacement: delete the old entity, create a new one with the exact state you want
await walletClient.deleteEntity({ entityKey })
const { entityKey: newEntityKey } = await walletClient.createEntity({
payload: jsonToPayload({ title: "New content" }),
contentType: "application/json",
attributes: { status: "done" },
expires: ExpirationTime.fromDays(7),
})

See Mutating Data for details.