---
name: arkiv-write-safety
description: Write to Arkiv — the queryable database on Ethereum — without losing data, duplicating writes, or colliding nonces. Covers the destructive surface of @arkiv-network/sdk (createEntity, updateEntity FULL-REPLACE read-merge-write, deleteEntity, extendEntity, changeOwnership, atomic mutateEntities batches), plus write queues for single-wallet nonce safety, $owner vs $creator semantics, create-then-changeOwnership handoffs, pre-flight checks (chainId, GLM balance, namespace), and idempotent retries with an error catalog (ZeroBtl, "No reason provided by backend.", NoEntityFoundError, Node 24 silent hang, "non-golembase transaction", nonce collisions). Activate whenever an agent is about to create, update, delete, extend, transfer ownership of, or batch-write Arkiv entities; sees nonce or revert errors on Braga; or asks why updateEntity erased attributes.
license: MIT
metadata:
  arkiv.skill-version: "1.1.0"
  arkiv.sdk-package: "@arkiv-network/sdk"
  arkiv.sdk-range: ">=0.6.5 <0.7.0"
  arkiv.sdk-verified: "0.6.8"
  arkiv.network-fingerprint: "braga:60138453102"
  arkiv.surface: "createPublicClient, createWalletClient, http, privateKeyToAccount, braga, eq, buildQuery, getEntity, createEntity, updateEntity, deleteEntity, extendEntity, changeOwnership, mutateEntities, getChainId, getBalance, ExpirationTime, jsonToPayload, NoEntityFoundError, formatEther"
  arkiv.last-verified-at: "2026-06-11"
  arkiv.evidence: "5 patterns from 124 hackathon submissions across 5 events"
---

# Arkiv Write Safety

Use this skill when code is about to touch Arkiv's destructive surface: `createEntity`, `updateEntity`, `deleteEntity`, `extendEntity`, `changeOwnership`, `mutateEntities` — every one signs a wallet transaction and spends GLM. The three ways hackathon teams lose hours here: **silent data loss** (full-replace updates dropping attributes), **duplicate writes** (retrying a write that actually landed), and **nonce collisions** (parallel writes from one wallet). This skill decides the safe default for each.

## Ground rules

- **Network (mutable — do not bake in):** Braga testnet, chainId `60138453102`, RPC `https://braga.hoodi.arkiv.network/rpc`, faucet `https://braga.hoodi.arkiv.network/faucet/`, explorer `https://explorer.braga.hoodi.arkiv.network`. **Networks rotate. Verify at https://docs.arkiv.network before relying on these. Testnet entities do NOT migrate between networks** (Kaolin sunset 2026-05-15 stranded every app built on it).
- **SDK:** `@arkiv-network/sdk` `>=0.6.5 <0.7.0` (verified against 0.6.8). `braga` is only importable from 0.6.5+; `mendoza`/`marketplace`/`rosario` were removed in 0.6.7 and `kaolin` is deprecated — old imports fail to compile.
- **Node: pin 22 LTS.** Node 24 hangs forever on entity updates (sdk issue #14): the tx SUCCEEDS on-chain but the promise never resolves — and a naive retry then mints a duplicate.
- **Keys:** throwaway testnet keys only, loaded from env, never committed, never real funds.
- **Isolate your write code.** Today writes are brotli-compressed RLP calldata to a magic address (`0x…61726b6976`); SDK PR #64 (draft) replaces the whole mechanism with a registry contract. Put every wallet-client call behind ONE module (e.g. `lib/arkiv-writes.ts`) so that migration touches one file.
- Expiration is set via `expiresIn` in **seconds** (a.k.a. TTL); prefer `ExpirationTime.fromDays(…)` helpers. There is no permanence primitive on Arkiv — a huge expiration is a workaround, not a guarantee; say so honestly in your app.

## Setup: two clients, one write module

The wallet client has **NO Arkiv read actions** — `getEntity`/`query`/`buildQuery` exist only on the public client. Every write flow below needs BOTH.

```ts
// lib/arkiv-writes.ts — the ONLY file that touches the wallet client
import { createPublicClient, createWalletClient, http } from "@arkiv-network/sdk"
import { privateKeyToAccount } from "@arkiv-network/sdk/accounts"
import { braga } from "@arkiv-network/sdk/chains"

export const publicClient = createPublicClient({ chain: braga, transport: http() })

export const walletClient = createWalletClient({
  chain: braga,
  transport: http(),
  account: privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`), // throwaway testnet key
})
```

- Do NOT `npm install viem` separately — the SDK re-exports all of viem (`http`, `formatEther`, accounts, …). A second viem risks version-mismatch bugs against the SDK's pinned `^2.38.2`.
- Do NOT import from `golem-base-sdk` — that is the stale legacy package. `createClient`, `GolemBaseClient`, "annotations", and `BTL`/`numberOfBlocks` belong to it, not to `@arkiv-network/sdk`. Never mix the two.

## Patterns

### read-merge-write: updateEntity is FULL REPLACE

`updateEntity` requires ALL of `payload`, `attributes`, `contentType`, `expiresIn` — every time. There is no partial patch. Sending a partial attribute list silently **erases every attribute you omit** (including your project-namespace attribute, so your own queries stop finding the entity). The corpus invented whole workaround architectures for this (friction in 4 of 124 submissions — RecallKit, OpenClu), and the official `arkiv-best-practices` skill's own `updateEntity` example sends a partial attribute list — the helper below is the correction. Default to it for every update:

```ts
import { NoEntityFoundError, type Attribute } from "@arkiv-network/sdk"
import { ExpirationTime } from "@arkiv-network/sdk/utils"

export async function patchEntity(
  entityKey: `0x${string}`,
  patch: { attributes?: Record<string, string | number>; payload?: Uint8Array; expiresIn?: number },
) {
  let current
  try {
    current = await publicClient.getEntity(entityKey) // throws NoEntityFoundError — never returns null
  } catch (err) {
    if (err instanceof NoEntityFoundError)
      throw new Error(`Entity ${entityKey} is gone (deleted or expired) — cannot patch`)
    throw err
  }

  const merged = new Map(current.attributes.map((a) => [a.key, a.value]))
  for (const [key, value] of Object.entries(patch.attributes ?? {})) merged.set(key, value)

  return enqueueWrite(() =>
    walletClient.updateEntity({
      entityKey,
      payload: patch.payload ?? current.payload!, // re-send or it is erased
      attributes: [...merged].map(([key, value]) => ({ key, value })) as Attribute[],
      contentType: current.contentType ?? "application/json",
      // updates restart the expiration clock — re-state your policy explicitly:
      expiresIn: patch.expiresIn ?? ExpirationTime.fromDays(30),
    }),
  )
}
```

Notes:

- Attribute values are `string | number` ONLY. `boolean`/`bigint` values are **silently dropped** at encode time. Numerics are integers server-side — scale decimals (`Math.round(x * 100)`).
- `deleteEntity({ entityKey })` is unrecoverable and leaves no audit trail. For revocable/archivable records, flip a `status` attribute via `patchEntity` instead (see arkiv-data-modeling); reserve delete for true removal.
- This read-modify-write is NOT atomic across writers — if two processes patch the same entity concurrently, last write wins. Route both through one write queue (below), or redesign so each writer owns its own entity.

### mutateEntities: the way to write more than ONE entity (one signature, one nonce)

**Writing more than one entity? Batch it — do not loop single writes.** Any time you have 2+ writes ready at once — entities that must coexist OR a bulk of independent records — they go in ONE `mutateEntities` call: one transaction, one signature, one nonce, all-or-nothing, **up to 1000 operations per tx** (creates + updates + deletes + extensions + ownershipChanges combined; chunk larger sets into batches of 1000). This is the default for bulk inserts/seeds, not just for entities that must coexist. It is also the answer to write-path slowness (the corpus's friction list shows ~8–15s end-to-end for 4 sequential on-chain steps, versus one batch) and to nonce collisions (one nonce, not N).

Seen in 4 of 124 submissions — VEIL (cut 8 signatures to 2–3), Arkmy (assistant message + all extracted insights atomically), voisss, Cortex (batched reinforcement extensions).

```ts
import { jsonToPayload, ExpirationTime } from "@arkiv-network/sdk/utils"

const result = await enqueueWrite(() =>
  walletClient.mutateEntities({
    creates: [
      { payload: jsonToPayload(parent), attributes: parentAttrs, contentType: "application/json", expiresIn: ExpirationTime.fromDays(30) },
      { payload: jsonToPayload(child), attributes: childAttrs, contentType: "application/json", expiresIn: ExpirationTime.fromDays(7) },
    ],
    // extendEntity semantics: ADDS seconds to the current expiration (also true in batches).
    // Classic use: push an existing parent's expiration out so a new child can't outlive it
    // (Web3 Job Board does exactly this, with a 12h buffer against races).
    extensions: [{ entityKey: existingParentKey, expiresIn: ExpirationTime.fromDays(7) }],
  }),
)
// result: { txHash, createdEntities[], updatedEntities[], deletedEntities[], extendedEntities[], ownershipChanges[] }
```

Sharp edges (sdk issue #67 — all verified against 0.6.8):

- **Max 1000 operations per `mutateEntities` tx** (creates + updates + deletes + extensions + ownershipChanges combined) — the protocol rejects a larger tx, so chunk into batches of ≤1000. Gas is NOT the limiter: 1000 tiny entities cost only ~55–93k gas total on Braga (measured 2026-06-16), well under the 60M block — the binding throughput limits are this 1000-op cap and the ~2s block cadence (so one wallet sustains ~500 entities/s at batchSize 1000), not gas.
- An `ownershipChanges`-ONLY batch throws **"No operations to perform"**. Use the standalone `changeOwnership` action, or batch the ownership change together with at least one other op.
- In mixed update+delete batches the returned `updatedEntities` / `deletedEntities` keys come back **SWAPPED** (verified with repro). Don't trust those two arrays in mixed batches — confirm with a read.
- `createdEntities` keys cannot be referenced from `ownershipChanges` in the SAME batch (keys only exist after the tx). Creates-then-transfer is two steps (next pattern).
- Single-op actions parse `receipt.logs[0].topics[1]` unguarded — an unexpected receipt shape throws a confusing TypeError, not a clean SDK error.

### write queue: for writes that arrive SEPARATELY over time

One wallet = one nonce sequence. Parallel `createEntity` calls from the same key collide on nonces and hammer the RPC. **First ask: do I have all these writes right now? → then it is ONE `mutateEntities` batch (above), not a queue.** The queue is for writes that genuinely arrive at different times (separate requests / ticks) and so can't be one batch:

Seen in 1 of 124 submissions as an explicit pattern — norte vivo ("one transaction per stop, sequentially, so as not to saturate the RPC"); the underlying pain hit 5 projects (corpus friction #8), including on-message (winner, 1st place etharg-2025), which shipped the comment "nonce collision caveat still applies when using same PK in 2 tabs", and OpenArkiv (winner, Best DePIN), whose gateway signer needed exactly this mutex.

```ts
// Promise-chain mutex: serializes EVERY write from this wallet. No dependency needed.
let tail: Promise<unknown> = Promise.resolve()
export function enqueueWrite<T>(op: () => Promise<T>): Promise<T> {
  const run = tail.then(op, op)  // run regardless of the previous op's outcome
  tail = run.catch(() => {})     // one failure must not poison the queue
  return run
}

// have an ARRAY of records right now? That is a batch, not a loop — one tx, one nonce:
await enqueueWrite(() =>
  walletClient.mutateEntities({ creates: records.slice(0, 1000).map(toCreate) }), // chunk at 1000
)
// the queue is for writes that arrive SEPARATELY (different requests/ticks), not a set you already hold:
await enqueueWrite(() => walletClient.createEntity(toCreate(lateArrival)))
```

Decision order: **(1) have more than one write at once? → batch with `mutateEntities`** (up to 1000 ops/tx, one nonce — related OR independent; this is the default, including bulk inserts/seeds); **(2) `enqueueWrite`** only for writes that genuinely arrive separately over time and so can't be batched; **(3) never raw `Promise.all` on wallet-client calls.** On serverless (Vercel/Lambda) the module-level queue only guards one warm instance — concurrent invocations are separate processes signing with the same key, so either route writes through a single queue-owning endpoint or give each worker its own key (see arkiv-app-architecture).

### $owner vs $creator: transferable control vs immutable attribution

Every entity carries two protocol fields. `$creator` = who originally wrote it — **immutable forever**. `$owner` = who can update/delete/extend it NOW — mutable via `changeOwnership`. Design question per entity type: "who must be provably the author vs who should control the record?"

Seen in 14 of 124 submissions — MeshOWN (calls it "the architectural centrepiece": AI agent's wallet as provable `$creator`, sensor owner as `$owner`), VEIL, Vitalchain, espresso, ClearShield, Licita Verify.

```ts
import { eq } from "@arkiv-network/sdk/query"

// Entities the user CONTROLS now (follows ownership transfers):
const controlled = await publicClient.buildQuery()
  .where(eq("project", PROJECT_NS))
  .ownedBy(userAddress)
  .withPayload(true).withAttributes(true)   // builder data defaults are EMPTY — always set these
  .limit(50)
  .fetch()

// Entities your service AUTHORED (survives transfer — this is your trust/injection filter too):
const authored = await publicClient.buildQuery()
  .where(eq("project", PROJECT_NS))
  .createdBy(serviceWalletAddress)
  .withPayload(true).withAttributes(true)
  .limit(50)
  .fetch()
```

Security corollary: anyone can write entities carrying YOUR project attribute on a shared testnet. Namespaces are organization, NOT auth — trusted reads must also filter `.createdBy(trustedWallet)`. (Full read-side discipline in arkiv-query-patterns.) Sort results in JS after fetching — never `.orderBy()`, the server silently ignores it and it's removed in 0.7.0.

### create-then-changeOwnership: server pays gas, user gets control

The canonical gasless-onboarding handoff: a server/agent wallet creates the entity (paying gas, staying `$creator` forever for attribution), then transfers `$owner` to the end user. The user never signs, yet ends up controlling their data.

Seen in 9 of 124 submissions — voisss, NotarIA (medical records handed to the doctor's wallet — "sovereign ownership with gas abstraction"), ArkivLog, Fact Receipts, Mnemos, Ark-Agent, holomem.

```ts
export async function createForUser(data: object, attributes: Attribute[], userAddress: `0x${string}`) {
  // Step 1: service wallet creates (it pays gas; $creator = service, permanently)
  const { entityKey } = await enqueueWrite(() =>
    walletClient.createEntity({
      payload: jsonToPayload(data),
      attributes,
      contentType: "application/json",
      expiresIn: ExpirationTime.fromDays(90),
    }),
  )
  // Step 2: hand control to the user ($owner = user; service can no longer mutate it)
  await enqueueWrite(() => walletClient.changeOwnership({ entityKey, newOwner: userAddress }))
  return entityKey
}
```

- Two steps, not one batch: the entityKey doesn't exist until the create tx lands (and an ownershipChanges-only batch throws anyway).
- After the transfer your service CANNOT update/delete/extend the entity. If you need ongoing service writes, keep ownership and give the user verifiability via `$creator` queries instead — decide before shipping.
- Verify a handoff with `(await publicClient.getEntity(entityKey)).owner === userAddress.toLowerCase()` style checks (compare case-insensitively).

### pre-flight checks: fail before the write, not during

Misconfiguration (wrong RPC, unfunded wallet, missing namespace) produces the worst error messages in the stack — teams wrap writes in pre-flight checks "to avoid invalid writes" (corpus friction #23). Run once at boot, before the first write:

```ts
import { formatEther } from "@arkiv-network/sdk" // viem re-export — don't install viem

export async function preflight() {
  const chainId = await publicClient.getChainId()
  if (chainId !== braga.id)
    throw new Error(`Wrong chain: RPC returned ${chainId}, expected ${braga.id} (braga). Fix the RPC URL — and re-verify network facts at docs.arkiv.network.`)

  const balance = await publicClient.getBalance({ address: walletClient.account.address })
  if (balance === 0n)
    throw new Error(`0 GLM on ${walletClient.account.address} — fund it at the Braga faucet (current URL: docs.arkiv.network).`)

  if (!PROJECT_NS) throw new Error("PROJECT_NS is unset — every write must carry the project-namespace attribute.")

  return { chainId, address: walletClient.account.address, glm: formatEther(balance) }
}
```

### idempotent retries: re-query before re-create

**The SDK never retries, and a timeout is NOT proof of failure.** Under the Node 24 bug (issue #14) and ordinary RPC blips, the tx can land on-chain while your promise hangs or rejects — blind retry mints duplicates. Make every retried create idempotent by checking for the entity first, keyed on a unique attribute:

```ts
const sanitize = (s: string) => s.replace(/["\\]/g, "") // '"' in user data breaks/alters the query (issue #67 #5)

export async function createOnce(uniqueId: string, params: Parameters<typeof walletClient.createEntity>[0]) {
  const existing = await publicClient.buildQuery()
    .where([eq("project", PROJECT_NS), eq("uniqueId", sanitize(uniqueId))])
    .withAttributes(true)
    .limit(1)
    .fetch() // fresh builder per query — fetch() mutates the builder
  if (existing.entities.length > 0) return existing.entities[0].key

  const { entityKey } = await enqueueWrite(() => walletClient.createEntity(params))
  return entityKey
}
```

Retry policy defaults: retry reads freely; retry writes ONLY through `createOnce`-style guards; on a hung/failed write, check the explorer for the tx before doing anything (the revert message won't help you — see catalog). For updates, re-running `patchEntity` is naturally idempotent (it re-reads first).

## Error catalog

| Error / symptom | Cause | Fix |
|---|---|---|
| `ZeroBtl` revert | `expiresIn: 0` passed through to the node | Always a positive number of seconds; use `ExpirationTime.fromDays(…)` |
| Cryptic viem `toHex` error on write | Negative `expiresIn` (e.g. `ExpirationTime.fromDate(pastDate)`) | Guard `expiresIn > 0` before sending |
| `"No reason provided by backend."` | The SDK swallows decoded revert reasons (issue #67 #1) | Open the `txHash` on the block explorer to see the real revert — never debug from this message |
| `NoEntityFoundError` | `getEntity` on a missing OR **expired** entity | try/catch (it throws, never returns null). Expiry is a normal lifecycle event — handle it as data, not as a crash |
| `NoCursorOrLimitError` | `QueryResult.next()` without `.limit(n)` on the builder | Always set `.limit()` when you might paginate |
| Write promise never resolves — but the tx IS on the explorer | Node 24 update hang (sdk issue #14, open) | Pin Node 22 LTS. Before any retry, verify on the explorer; retry only via `createOnce` |
| `nonce too low` / replacement-underpriced / sporadic write failures under load | Parallel writes from one wallet (incl. same key in 2 tabs / 2 serverless instances) | Have the writes at once? **batch into one `mutateEntities`** (≤1000 ops, one nonce); otherwise serialize with the `enqueueWrite` queue; never `Promise.all` raw wallet calls |
| `"non-golembase transaction"` | A plain native value transfer sent to the chain (e.g. trying to fund a wallet by direct transfer) | The chain only accepts Arkiv ops (writes carry `value: 0`). Fund wallets via the faucet or the official bridge |
| insufficient funds for gas | Wallet has no GLM | Braga faucet — current URL via https://docs.arkiv.network (faucet location/amounts are mutable) |

## Anti-patterns / gotchas (numbers = SDK survey)

0. **Looping single writes for a set you already have** — calling `createEntity`/`updateEntity` in a `for`/`map` over an array you already hold collides on the nonce, is far slower, and is non-atomic. N writes you have at once = ONE `mutateEntities` call (≤1000 ops), never N calls. (This is the #1 thing an LLM gets wrong — `createEntity` looks like the obvious tool; it is the SINGLE-write tool.)
1. **(#1)** Partial `updateEntity` — silently erases omitted attributes/payload. Always read-merge-write with the COMPLETE attribute set.
2. **(#5)** Calling `getEntity`/`buildQuery` on the wallet client — those actions don't exist there. Two clients, always.
3. **(#6)** Node 24 — updates hang while the tx succeeds on-chain. Pin Node 22 LTS (`"engines": { "node": "22.x" }`).
4. **(#12)** Importing `createClient`/`GolemBaseClient`/annotations/`BTL` — legacy `golem-base-sdk` package. Never mix it with `@arkiv-network/sdk`.
5. **(#13)** Installing viem alongside the SDK — the SDK re-exports all of viem; a second copy invites version-mismatch bugs.
6. **(#14)** Trusting `mutateEntities` return arrays in mixed update+delete batches (keys swapped), or sending ownershipChanges-only batches ("No operations to perform").
7. **(#16)** Debugging from `"No reason provided by backend."` — the real revert reason is only on the explorer.
8. **(#18)** Importing `kaolin`/`mendoza` chains — dead or removed; only `braga` is live (and Braga itself will rotate — re-verify at docs.arkiv.network).
9. **(#19)** Scattering wallet-client calls across the codebase — the write mechanism changes in PR #64; keep writes in one module.

## Compose with

- **arkiv-data-modeling** — design the schema (namespace attribute, entity types, soft-delete status) BEFORE writing; this skill assumes those attributes exist.
- **arkiv-expiration-design** — choosing `expiresIn` per entity type, parent/child expiry alignment, extend-as-heartbeat.
- **arkiv-query-patterns** — the read side: pagination, fresh builders, injection-safe query values in depth.
- **arkiv-app-architecture** — who holds the key (server signer vs embedded vs browser wallet), serverless write topology, env hygiene, deployment.
- **arkiv-agent-state** — use the Arkiv MCP to inspect/verify what your code wrote (the SDK writes, the MCP proves).
