--- name: arkiv-first-write description: Set up @arkiv-network/sdk on a verified Arkiv testnet and make a first entity write with funding, safe key custody, explicit expiration, seed reconciliation and query read-back. Tiramisu is the default. Use for a first write, quickstart or minimal SDK example; use arkiv-app-integration for authenticated application endpoints. license: MIT metadata: arkiv-sdk: ">=0.8.1 <0.9" network: "tiramisu" verified: "2026-10-08" --- # First Arkiv write Use `arkiv` for orientation. This skill produces one verified entity, not a production signing API. Read `arkiv-write-safety` before bulk imports or recovery from an ambiguous submission. ## Prepare the project Before producing executable SDK code, observe the project's installed SDK version and inspect its installed declarations. If either is unavailable, request that evidence or approval to obtain it before giving executable SDK code; an "unverified" label does not satisfy this gate. Recommend SDK `0.8.1` exactly or state the supported range literally as `>=0.8.1 <0.9`; never describe `0.8.x` as a compatibility guarantee. If the project uses SDK `0.8.0`, stop integration and ask for approval to upgrade, without forcing or bypassing peers. Report only files you actually inspected. Inspect the existing package manager and installed SDK declarations. Explain a dependency installation before running it. For a new npm project: ```bash npm install @arkiv-network/sdk@^0.8.1 viem ``` Configure `ARKIV_PRIVATE_KEY` locally in a server-only environment. Never request its value in chat. Use a dedicated development EOA, not a production account. An access key, if needed, goes in a server-side `X-API-KEY` header bound to the official RPC origin; it does not fund transactions. The helpers below reject access keys for other origins and refuse redirects. Announce **Tiramisu, chain 7738577**, and the proposed entity before sending. A live write spends test GLM: get authorization for that write or a bounded smoke-test budget. Check the balance and estimate the actual transaction; a positive balance alone does not establish enough gas. Use the [Hub faucet](https://hub.arkiv.network/faucet) without promising an amount or cooldown. If spending is not authorized, stop at preparation and estimation. For another Arkiv chain, announce its verified name, chain ID and RPC instead. SDK 0.8.1 exports Tiramisu and localhost; an RPC URL alone does not change a client's chain. Build a viem `Chain` from verified network coordinates and pass it explicitly to the helper and the [dry run](references/estimate.md). Check funding on that chain. Never rename Tiramisu or invent an SDK chain export. SDK 0.8.1 has no public create-estimator method. Use the guarded [dry run](references/estimate.md) with the same create parameters: it captures the SDK's gas estimate and blocks every broadcast. It is not a live write/read-back test. ## Create, reconcile a seed, and read back The following server-only helper assumes the write has been authorized. Pass a locally loaded `LocalAccount` from viem's `privateKeyToAccount`; validate that the configured value is a 32-byte hex key without logging it. No environment secret is read at import time. Use an exclusive writer for this creator while running the seed. Query-before-create prevents routine repeated runs; it is **not atomic uniqueness** across processes. A reset chain or expired seed means a new key, so rebuild references. This example makes its note readonly; its owner can still delete, transfer or extend it. Save the helper as `first-note.ts` and the [guarded estimator](references/estimate.md) as `estimate-create.ts`. Import their exports in your server entrypoint and call `ensureFirstNote(account, rpcUrl, chain, { accessKey, estimateGas: data => estimateCreate(account, data, rpcUrl, chain, accessKey) })`. The callback estimates the same frozen parameters without broadcasting. A fresh seed fails closed if it is missing; seed reuse needs no estimator. ```typescript import { createPublicClient, createWalletClient, EntityMutationError, ExpirationTime, jsonToPayload, type CreateEntityParameters, } from "@arkiv-network/sdk"; import { key, u64 } from "@arkiv-network/sdk/attr"; import { tiramisu } from "@arkiv-network/sdk/chains"; import { eq } from "@arkiv-network/sdk/query"; import { http, type Chain, type LocalAccount, type TransactionReceipt } from "viem"; import { estimateFeesPerGas } from "viem/actions"; type FirstWriteOptions = { accessKey?: string; estimateGas?: (data: CreateEntityParameters) => Promise; }; export function firstNoteParameters(): CreateEntityParameters { return { payload: jsonToPayload({ text: "Hello, Arkiv" }), contentType: "application/json", attributes: { project: "first_note", entity_type: "note", seed_id: "welcome_v1", created_at: u64(Date.now()), }, expires: ExpirationTime.fromDays(1), flags: { readonly: true, permissionlessExtension: false }, }; } export async function ensureFirstNote( account: LocalAccount, rpcUrl: string = tiramisu.rpcUrls.default.http[0], chain: Chain = tiramisu, options: FirstWriteOptions = {}, ) { if (options.accessKey && new URL(rpcUrl).origin !== new URL(tiramisu.rpcUrls.default.http[0]).origin) { throw new Error("Access key origin mismatch"); } const fetchOptions = { cache: "no-store" as const, redirect: "error" as const, ...(options.accessKey ? { headers: { "X-API-KEY": options.accessKey } } : {}) }; const publicClient = createPublicClient({ chain, cacheTime: 0, transport: http(rpcUrl, { fetchOptions, retryCount: 0, timeout: 10_000 }), }); if (await publicClient.getChainId() !== chain.id) throw new Error("Wrong chain"); const expected = jsonToPayload({ text: "Hello, Arkiv" }); const matches = (payload: Uint8Array) => payload.length === expected.length && payload.every((value, i) => value === expected[i]); const prior = await publicClient .select({ key: true, payload: true, expiresAt: true, creationFlags: true, contentType: true, attributes: true }) .where(eq("project", "first_note"), eq("entity_type", "note"), eq("seed_id", "welcome_v1")) .createdBy(account.address) .atBlock(await publicClient.getBlockNumber({ cacheTime: 0 })) .limit(2) .fetch(); if (prior.entities.length > 1 || prior.hasNextPage()) throw new Error("Duplicate seed: reconcile"); const existing = prior.entities[0]; if (existing) { if (!existing.creationFlags.readonly || existing.creationFlags.permissionlessExtension || existing.contentType !== "application/json" || !matches(existing.payload) || existing.attributes.created_at?.type !== "u64") { throw new Error("Seed differs: reconcile instead of overwriting"); } return { entityKey: existing.key, expiresAt: existing.expiresAt.toString(), reused: true }; } const balance = await publicClient.getBalance({ address: account.address }); if (balance === 0n) { throw new Error("Fund the signer before writing"); } if (typeof options.estimateGas !== "function") throw new Error("Configure guarded estimateCreate before writing"); const parameters = firstNoteParameters(); const estimated = await options.estimateGas(parameters); if (typeof estimated !== "bigint" || estimated <= 0n) throw new Error("Invalid gas estimate"); const gas = (estimated * 12n + 9n) / 10n; // Round up this helper's 20% gas margin. const fees = await estimateFeesPerGas(publicClient); if (balance < gas * fees.maxFeePerGas) throw new Error("Insufficient funds for estimated gas and fee cap"); const walletClient = createWalletClient({ account, chain, cacheTime: 0, transport: http(rpcUrl, { fetchOptions, retryCount: 0, timeout: 10_000 }), }); let created; try { // createEntity returns { entityKey, txHash, expiresAt }, without a receipt. created = await walletClient.createEntity(parameters, { gas, ...fees }); } catch (error) { if (error instanceof EntityMutationError && error.txHash) { // This may be a revert, a receipt-decode failure, or a timeout with the tx still pending. // Inspect receipt status/logs and the scoped seed before deciding to send again. throw Object.assign(new Error(`Reconcile transaction ${error.txHash}; do not resend yet`, { cause: error }), { txHash: error.txHash }); } throw error; // No hash also requires nonce/pending/seed inspection if broadcast was possible. } let receipt: TransactionReceipt | undefined; try { receipt = await publicClient.getTransactionReceipt({ hash: created.txHash }); if (receipt.status !== "success") throw new Error("Create receipt is not successful"); const page = await publicClient .select({ key: true, payload: true, expiresAt: true, contentType: true, creationFlags: true, attributes: true }) .where(eq("$key", key(created.entityKey))) .atBlock(receipt.blockNumber).limit(1).fetch(); const entity = page.entities[0]; const timestamp = entity?.attributes.created_at; const expectedTimestamp = parameters.attributes?.created_at; if (!entity || entity.key !== created.entityKey || !matches(entity.payload) || entity.contentType !== parameters.contentType || !entity.creationFlags.readonly || entity.creationFlags.permissionlessExtension || timestamp?.type !== "u64" || typeof expectedTimestamp !== "object" || expectedTimestamp?.type !== "u64" || timestamp.value !== expectedTimestamp.value) throw new Error("Read-back mismatch"); if (entity.expiresAt !== created.expiresAt || entity.expiresAt - receipt.blockNumber !== parameters.expires.minLifetime) { throw new Error("Create expiration does not match its requested lifetime"); } return { entityKey: entity.key, txHash: created.txHash, receiptBlock: receipt.blockNumber.toString(), gasUsed: receipt.gasUsed.toString(), expiresAt: entity.expiresAt.toString(), reused: false }; } catch (error) { throw Object.assign(new Error("Confirmed write needs reconciliation; do not resend", { cause: error }), { entityKey: created.entityKey, txHash: created.txHash, expiresAt: created.expiresAt.toString(), receiptBlock: receipt?.blockNumber.toString(), gasUsed: receipt?.gasUsed.toString(), }); } } ``` The returned `expiresAt` is decoded from the create receipt in SDK 0.8.1. It is a block number, while `created_at` above is an application timestamp in milliseconds. Payload and attributes are public. Readonly and Entity Expiration do not make them private. Inspect the confirmed transaction and entity in the [Tiramisu explorer](https://tiramisu.explorer.arkiv.network): entity route `/entity/` and transaction route `/tx/`. If the host already has read-only verification tools, use their actual available schemas; the SDK and explorer suffice without an integration. Keep the returned key and hash as separate values, including structured error fields after a failed read-back. ## Add project rules with consent Offer this candidate block for the consumer project's `AGENTS.md`. Ask before editing that file; preserve its existing guidance. The broader evaluation suite may refine these rules later. ```markdown ## Arkiv rules for this project - Use @arkiv-network/sdk >=0.8.1 <0.9 on Tiramisu; inspect installed version/declarations before SDK calls. If unavailable, obtain them before producing executable SDK code; never guess exports. - Attribute names start with a lowercase letter and use lowercase letters, digits and underscores; exclude reserved words. - Use u64(Date.now()) for millisecond timestamps in writes and matching queries; bare numbers become i32. - Access keys and local signing keys stay server-side; never put them in URLs, chat or public environment variables. - Load the arkiv router for Arkiv work. ``` ## Failures and testing - `InvalidValueError` mentioning i32: use `u64` in both write and query. - `outside the name charset` / `Ident32InvalidByte` / `0x276f7798`: Tiramisu rejects uppercase attribute names even though the SDK's rendered charset message lists A–Z; rename them to lowercase. - `InvalidContentTypeError`: use a lowercase MIME type/subtype, such as `application/json`, without parameters or whitespace. - `EntityMutationError` or missing read-back: reconcile; never treat a second create as repair. - Seed collision, wrong chain, insufficient funding or missing estimator: stop before sending. Read [testing.md](references/testing.md) for realistic fixtures and a bounded live smoke. Read `arkiv-query` for multi-page seeds; `arkiv-app-integration` for browser wallets and authenticated routes. Sources: [SDK 0.8.1](https://www.npmjs.com/package/@arkiv-network/sdk/v/0.8.1), [create implementation](https://unpkg.com/@arkiv-network/sdk@0.8.1/src/actions/wallet/createEntity.ts), [query documentation](https://docs.arkiv.network/json-rpc/querying-data/), [faucet](https://hub.arkiv.network/faucet).