Best Practices
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.
1. Always Use a Project Attribute
Section titled “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 that exports this attribute:
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_ATTRIBUTEconst { 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_ATTRIBUTEconst result = await publicClient .select({ key: true, payload: true }) .where( eq(PROJECT_ATTRIBUTE.key, PROJECT_ATTRIBUTE.value), eq("entity_type", "post"), ) .limit(50) .fetch()2. Separate Read and Write Clients
Section titled “2. Separate Read and Write Clients”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.
3. Design Attributes for Queryability
Section titled “3. Design Attributes for Queryability”Attributes are your indexes. Without the right ones, you will fetch too much data and filter client-side.
// Good: attributes map to your query patternsattributes: { 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}4. Use Batch Operations
Section titled “4. Use Batch Operations”Individual creates in a loop are slow and expensive. Use executeBatch():
// Bad: sequential, slowfor (const item of items) { await walletClient.createEntity(item)}
// Also bad: parallel writes from one wallet collide on the transaction nonceawait Promise.all(items.map((item) => walletClient.createEntity(item)))
// Good: single batch operation, single transactionawait 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.
5. Write Specific Queries
Section titled “5. Write Specific Queries”Broad queries return too much data. Always add multiple filter criteria:
// Bad: returns every note everawait publicClient.select({ key: true }).where(eq("type", "note")).fetch()
// Good: narrows to what you needawait 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.
6. Right-Size Expiration
Section titled “6. Right-Size Expiration”Match expires to actual data lifetime:
| Use Case | Duration |
|---|---|
| Session data | ExpirationTime.fromMinutes(30) |
| Cache | ExpirationTime.fromHours(1) |
| Temp files | ExpirationTime.fromHours(24) |
| Weekly data | ExpirationTime.fromDays(7) |
Do not over-allocate: it costs more and pollutes queries with stale data.
7. Never Expose Private Keys
Section titled “7. Never Expose Private Keys”CAUTION: Do not hardcode a private key in your source code. Anyone who reads the file can spend your funds.
// Always load from environmentconst privateKey = process.env.PRIVATE_KEY
// Never hardcodeconst privateKey = "0x1234..." // DANGEROUS8. Validate Input Before Storing
Section titled “8. Validate Input Before Storing”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), })}9. Use Numeric Types for Numeric Data
Section titled “9. Use Numeric Types for Numeric Data”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 }10. Model Related Data with Shared Attributes
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 entityattributes: { type: "proposal" }
// Vote entities reference the proposalattributes: { type: "vote", proposal_key: proposalEntityKey,}
// Query all votes for a proposalawait publicClient .select({ key: true, payload: true }) .where(eq("type", "vote"), eq("proposal_key", proposalEntityKey)) .fetch()11. Understand $owner vs $creator
Section titled “11. Understand $owner vs $creator”$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.
12. Filter by Creator for Trusted Data
Section titled “12. Filter by Creator for Trusted Data”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():
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()13. Handle Errors Gracefully
Section titled “13. Handle Errors Gracefully”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)}14. Validate Entity Data with Schemas
Section titled “14. Validate Entity Data with Schemas”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 skillsattributes: { skills: "frontend, backend, devops",}Instead, create separate relationship entities:
// 1. Create the profileconst { 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 skillconst 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" skillconst 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 untouchedawait 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 wantawait 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.