Skip to content

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

This page shows you how to write entities with the wallet client of the TypeScript SDK. It covers what an entity is made of, how to pick an attribute type, and how to build an expiry. It then walks through the five write operations, batching, and concurrent writes. Every write is a signed Ethereum transaction, so you need a wallet with test GLM from the faucet.

A wallet client signs and sends transactions, so it carries an account. On a server, build the account from a private key:

import { createWalletClient } from "@arkiv-network/sdk"
import { tiramisu } from "@arkiv-network/sdk/chains"
import { http } from "viem"
import { privateKeyToAccount } from "viem/accounts"
const walletClient = createWalletClient({
chain: tiramisu,
transport: http(),
account: privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`),
})

http() with no URL uses the public RPC endpoint configured as the default in the tiramisu chain. That endpoint is heavily rate limited and meant for testing. To raise the rate limit for a production build, get a free access key from the Arkiv Hub and append it to the end of the RPC URL.

Use of the public RPC is subject to the Arkiv Terms of Use and Privacy Policy.

The wallet client only writes. Reads such as getEntity(), select() and predictEntityKeys() live on the public client, so most apps create both.

Write from the browser with a wallet extension

Section titled “Write from the browser with a wallet extension”

In a browser, the user’s wallet extension signs instead of a private key. Ask it for an account, then pass the extension as the transport and the account address to the client:

import { createWalletClient } from "@arkiv-network/sdk"
import { tiramisu } from "@arkiv-network/sdk/chains"
import { custom } from "viem"
const [account] = await window.ethereum.request({
method: "eth_requestAccounts",
})
const walletClient = createWalletClient({
chain: tiramisu,
transport: custom(window.ethereum),
account,
})

The extension asks the user to confirm every transaction.

An entity has contents, a set of indexed attributes, and a lifetime. You write the first three rows of this table, and the engine fills in the rest:

PartSet byWhat it is
payload, contentTypeyouThe contents and their MIME type. Stored, returned, never indexed
attributesyouUp to 30 typed name-value pairs. Indexed, so queries filter on them
expiresyouThe block the entity stops existing at
flags, saltyou, optionalImmutable properties and the key salt. See Create an entity
keyengineA 32-byte identifier derived from your wallet, a per-wallet counter, and the salt
owner, creatorengineYour wallet. The owner changes on transfer, the creator never does
createdAt, updatedAt, expiresAtengineBlock heights

Put the data you want to filter on in attributes, and everything else in the payload. An attribute is short and typed. A payload is up to 128 KiB of anything.

Every attribute value carries a type, and the type decides which query operators work on it. Choose the type when you write the value, because a query can only ask for what the index holds.

An attribute name is 1 to 32 ASCII characters: a lowercase letter first, then lowercase letters, digits, ., - and _. Uppercase letters are rejected, so write names in snake_case, such as created_at_ms. Three more kinds of name are reserved:

  • Names that start with $, which belong to the system attributes
  • Names that contain --, which opens a comment in the query language
  • Words the query language reserves: and, or, not, true, false, startswith, exists, typeof, and the type tags bool, i32, u64, u256, dec, bytes32, bytes, str, addr, key

Import the constructors from @arkiv-network/sdk/attr. The table lists what each type accepts, which operators it supports, and what it is for:

TypeConstructorAcceptsOperatorsUse it for
strstr("open")A UTF-8 string up to 128 bytes, no control characterseq, startsWithCategories, status values, slugs, short identifiers
boolbool(true)true or falseeqFlags such as pinned or draft
i32i32(42)An integer from −2,147,483,648 to 2,147,483,647eq, rangePriorities, versions, small counters, scores that can be negative
u64u64(Date.now())A non-negative integer below 2⁶⁴eq, rangeMillisecond timestamps, block heights, anything compared with $expiresAt
u256u256(10n ** 18n)A non-negative integer below 2²⁵⁶eq, rangeToken amounts in wei, hashes read as numbers
decdec("19.99")A decimal string with up to 18 fractional digitseq, rangePrices, ratings, coordinates, anything with a fraction
addraddr("0x1234…")A 20-byte Ethereum addresseqWallets: authors, recipients, voters
keykey(otherKey)A 32-byte entity keyeqReferences to other entities
bytes32bytes32(hash)Any 32 byteseqHashes, digests, identifiers from other systems

Range means gt, gte, lt and lte. Only the four numeric types carry an ordered index. A number stored as a str supports equality and prefix matching and nothing else, so store numbers as numbers.

Build every value with the constructor for its type, even where the SDK would accept a bare JavaScript value. A constructor is highly recommended, because it rules out a type mismatch between what you store and what you query.

The same rule applies to queries. A predicate asserts the type of its value, so eq("seller", str("0x1234…")) and eq("seller", addr("0x1234…")) are different questions, and only the one that matches the stored type finds the entity.

The four numeric types differ in range and in whether they hold a fraction:

  • i32: anything that fits in 32 bits, and the only signed integer type. Date.now() does not fit, so timestamps belong in u64
  • u64: the width the protocol counts in. Block heights and $expiresAt are u64, so a value you want to compare with them must be a u64 too
  • u256: values that can outgrow 64 bits, such as token amounts in wei
  • dec: a signed fixed-point decimal with up to 18 fractional digits. Pass fractions as strings, such as dec("0.1"), because a JavaScript float cannot represent most decimals exactly

dec values come back as decimal strings such as "3.5".

The non-numeric types each have one rule worth knowing:

  • str: at most 128 UTF-8 bytes. Long or free-form text belongs in the payload, with a short key in the attribute
  • addr: an Ethereum address, stored checksummed
  • key: a weak reference to another entity. Nothing checks that the target exists, and a reference to a deleted or expired entity is allowed to dangle
  • bytes32: any other 32 bytes, such as a keccak256 digest

Attributes are flat, typed cells, and three things do not fit:

  • Arrays and objects: there is no list type. Model a list as one relationship entity per item, as Model lists with relationship entities shows
  • Empty values: an attribute is either set or absent, so leave the name out instead of passing undefined or null
  • More than 30 of them: one operation carries at most 32 attribute cells, and the payload and the content type each take one

A full attribute map with every kind of value looks like this:

import {
addr, bool, bytes32, dec, i32, key, str, u64, u256,
} from "@arkiv-network/sdk/attr"
const attributes = {
project: str("my_project"),
type: str("listing"),
active: bool(true),
priority: i32(3),
created_at_ms: u64(Date.now()),
bid_wei: u256(1_000_000_000_000_000_000n),
price: dec("19.99"),
seller: addr("0x1234567890123456789012345678901234567890"),
parent: key(parentEntityKey),
content_hash: bytes32(contentHash),
}

The payload is a Uint8Array of up to 128 KiB, paired with a lowercase MIME type. Two helpers from the package root encode the common cases:

import { jsonToPayload, stringToPayload } from "@arkiv-network/sdk"
const json = jsonToPayload({ title: "My note", body: "Hello" })
const text = stringToPayload("Hello Arkiv")

jsonToPayload() runs JSON.stringify and encodes the result as UTF-8. Pair it with contentType: "application/json" so entity.toJson() can read it back. stringToPayload() encodes a string as UTF-8. Pair it with text/plain and read it back with entity.toText().

For binary data, pass the bytes directly and read them back from entity.payload. An empty Uint8Array is valid for an entity that is nothing but attributes.

The content type is a lowercase MIME type such as image/png. The MimeType type lists common values for autocompletion, and any other lowercase string is accepted. Payloads are capped at 128 KiB, so store larger data elsewhere and keep a reference on the entity.

Every entity expires at a block height, and Arkiv counts lifetimes in blocks of 2 seconds. Build the expires value with ExpirationTime.

Choose between a variable and an exact expiry

Section titled “Choose between a variable and an exact expiry”

On the wire an expiry is two numbers: an absolute target block, and a minimum lifetime in blocks. The engine resolves them against the block the transaction lands in and takes whichever is later. Each ExpirationTime helper fills in one of the two:

import { ExpirationTime } from "@arkiv-network/sdk"
// Variable: a lifetime, counted from the block the transaction lands in
ExpirationTime.fromDays(30)
ExpirationTime.fromHours(6)
ExpirationTime.fromBlocks(1_000)
// Exact: an absolute block height, or a date converted to one
ExpirationTime.atBlock(1_200_000n)
ExpirationTime.atDate(new Date("2027-01-01T00:00:00Z"))

A variable expiry, from fromSeconds() up to fromYears(), is a duration. The engine adds it to the block the transaction lands in, so the entity lives that long from the moment the write lands. The block the engine settled on comes back as expiresAt from createEntity() and extendEntity().

Durations convert to blocks at the nominal 2-second block time, and block production is not a clock, so a 30-day lifetime is approximately 30 days. fromMonths() counts 30 days per month and fromYears() 365 days per year. fromBlocks() is the one duration with no conversion.

An exact expiry, from atBlock() or atDate(), names the block itself. The entity expires at that block whenever the transaction lands, which is what you want for a deadline shared across a set of entities or with another system. atDate() converts the date to a block against the current head and rounds up, so the entity lives until at least that instant.

Combine a deadline with a minimum lifetime

Section titled “Combine a deadline with a minimum lifetime”

An exact expiry that is too close to the current block is rejected as dead on arrival, and one that is close but valid can leave less life than you meant. atLeast puts a floor under a deadline. The engine takes the later of the two: the deadline holds when there is time, and the floor raises it when there is not.

// Expire at block 1,200,000, but never sooner than 30 seconds after landing
ExpirationTime.atBlock(1_200_000n, {
atLeast: ExpirationTime.fromSeconds(30),
})

The floor takes a duration from the from* helpers, never another deadline. With this floor, a transaction that lands 10 seconds before block 1,200,000 gives the entity 30 seconds instead of 10. One that lands a day before leaves the deadline as it is.

Four rules apply to every expiry:

  • A duration must be a whole number of blocks, so a multiple of 2 seconds. fromSeconds(7) is rejected, fromSeconds(8) works
  • The resolved expiry must be after the block the transaction lands in
  • A lifetime has no upper bound except the 64-bit width of the field. ExpirationTime.permanent() sets the largest block height a u64 holds, which is so far in the future that the entity is permanent in practice
  • An extension never shortens a life. A later expiry moves it out, an equal one is a no-op, and an earlier one is rejected

From its expiry block on, an entity is gone. Queries no longer return it, getEntity() throws NoEntityFoundError, and every write against it is rejected. There is no event and no grace period.

Two habits cover this. Start with a short lifetime and extend it while the data still matters, because you pay for the lifetime you choose. And if you mirror Arkiv data elsewhere, track expiresAt yourself, or query $expiresAt for entities that expire soon as Querying data shows.

createEntity() takes the contents, the attributes and the lifetime, and resolves once the transaction is in a block:

import { ExpirationTime, jsonToPayload } from "@arkiv-network/sdk"
import { dec, str, u64 } from "@arkiv-network/sdk/attr"
const { entityKey, txHash, expiresAt } = await walletClient.createEntity({
payload: jsonToPayload({ title: "Standing desk", body: "Barely used" }),
contentType: "application/json",
attributes: {
project: str("my_project"),
type: str("listing"),
price: dec("249.00"),
posted_at_ms: u64(Date.now()),
},
expires: ExpirationTime.fromDays(30),
})

The parameters are:

ParameterTypeRequiredDescription
payloadUint8ArrayyesThe contents. See Encode the payload
contentTypestringyesA lowercase MIME type
attributesAttributeInputsnoUp to 30 typed values, keyed by name
expiresExpiryyesBuilt with ExpirationTime
flagsCreationFlagsnoreadonly and permissionlessExtension. Both default to false
saltbigint or NO_SALTnoMixed into the key. Defaults to 128 random bits

The result carries the new entityKey, the txHash, and expiresAt, the block the engine recorded as the expiry.

Two properties are set when an entity is created and can never change afterwards:

  • readonly: freezes the attributes and the payload. The entity can still be extended, transferred, and deleted. Use it for records that must not change once published
  • permissionlessExtension: lets any wallet extend the expiry, not only the owner. Use it when a third party should be able to keep something alive without being able to change it
await walletClient.createEntity({
payload: jsonToPayload({ hash: "sha256:1234567890abcdef" }),
contentType: "application/json",
attributes: { project: str("my_project"), type: str("attestation") },
expires: ExpirationTime.fromYears(1),
flags: { readonly: true, permissionlessExtension: true },
})

The engine derives the key from your wallet, a per-wallet entity counter, and a random 128-bit salt. The salt means nobody but you can predict the key of an entity you are about to create. You rarely need to touch the salt. The one case is a batch in which one entity references another created in the same batch. See Reference an entity created in the same batch.

patchEntity() changes only what it names. Attributes you do not mention keep their values, and the payload and content type stay as they are unless you pass new ones:

import { i32, str } from "@arkiv-network/sdk/attr"
const { txHash } = await walletClient.patchEntity({
entityKey,
set: { status: str("sold"), version: i32(2) },
unset: ["featured"],
})

Because a patch names only the change, you can update one field without reading the entity first, and two writers changing different fields do not race each other. The parameters are:

ParameterTypeDescription
entityKeyHexThe entity to patch
setAttributeInputsAttributes to write. An existing name is overwritten, a new name is added
unsetstring[]Attribute names to remove. A name the entity does not carry is not an error
payloadUint8ArrayNew contents. Omit it to keep the current payload
contentTypestringNew MIME type. Omit it to keep the current one

set can change an attribute’s type, so set: { level: u64(5) } on an i32 level stores a u64. A query written for the old type stops matching that entity, so keep types stable within a project.

A patch never moves the key, the owner, the creation flags or the expiry. Only the owner can patch, and a readonly entity rejects every patch. For a full replacement with a clean attribute set, delete the entity and create a new one.

extendEntity() sets a new expiry. It is not additive: a duration counts from now, not from the current expiry, so fromDays(30) means alive for 30 more days whatever the entity had left:

const { expiresAt } = await walletClient.extendEntity({
entityKey,
expires: ExpirationTime.fromDays(30),
})

The new expiry must not be earlier than the current one. atBlock() and atDate() work here too, so you can pin an entity to a calendar date.

The owner can always extend. Any wallet can extend an entity created with permissionlessExtension. The result carries expiresAt, the block the engine recorded.

changeOwnership() hands the entity to another wallet:

const { txHash } = await walletClient.changeOwnership({
entityKey,
newOwner: "0x1234567890123456789012345678901234567890",
})

After the transfer you can no longer patch, extend or delete the entity, and the new owner can. $owner changes. $creator does not, so a createdBy() filter still finds it.

deleteEntity() removes the entity at once, before its expiry:

const { txHash } = await walletClient.deleteEntity({ entityKey })

Only the owner can delete, and readonly does not prevent it. Letting an entity expire costs nothing, so delete only what must go now.

executeBatch() applies any mix of the five operations in one transaction. The batch is atomic: every operation applies, or none does, and each later operation sees the effects of the earlier ones:

const result = await walletClient.executeBatch({
creates: [
{
payload: jsonToPayload({ body: "First" }),
contentType: "application/json",
attributes: { project: str("my_project"), type: str("note") },
expires: ExpirationTime.fromDays(7),
},
],
patches: [{ entityKey: noteToArchive, set: { status: str("archived") } }],
extensions: [{ entityKey: noteToKeep, expires: ExpirationTime.fromDays(30) }],
ownershipChanges: [{ entityKey: noteToHandOver, newOwner: recipient }],
deletes: [{ entityKey: staleNote }],
})
result.createdEntities // keys of the new entities, in batch order

Each list takes the same parameters as the single-entity method. The result carries txHash and one array of keys per operation kind: createdEntities, patchedEntities, deletedEntities, extendedEntities and ownershipChanges.

The SDK orders a batch as creates, then patches, deletes, extensions, and ownership changes. A later operation can target an entity created earlier in the same batch, but only if you know its key in advance. The next section shows how.

Use a batch whenever you have more than one write to make. A loop of single writes costs a transaction each, and parallel single writes from one wallet collide on the nonce, as Concurrent writes and nonces explains:

const items = ["frontend", "backend", "devops"]
await walletClient.executeBatch({
creates: items.map((skill) => ({
payload: jsonToPayload({ profileId: "alice-123", skill }),
contentType: "application/json",
attributes: {
project: str("my_project"),
type: str("skill"),
profile_id: str("alice-123"),
skill: str(skill),
},
expires: ExpirationTime.fromDays(30),
})),
})

Reference an entity created in the same batch

Section titled “Reference an entity created in the same batch”

A key is derived from the wallet, its entity counter and the salt, and predictEntityKeys() on the public client computes it before the create lands. That lets a second entity store a key attribute pointing at the first, in the same transaction:

  1. Predict the keys. Pass the count of creates the batch will carry. Each predicted key comes paired with the salt that produces it.

    const [thread, reply] = await publicClient.predictEntityKeys({
    owner: walletClient.account.address,
    count: 2,
    })
  2. Send the batch with those salts. A create that picks its own random salt would land on a different key, so pass salt from the prediction on every create.

    import { key, str } from "@arkiv-network/sdk/attr"
    await walletClient.executeBatch({
    creates: [
    {
    payload: jsonToPayload({ title: "Thread" }),
    contentType: "application/json",
    attributes: { project: str("my_project"), type: str("thread") },
    expires: ExpirationTime.fromDays(30),
    salt: thread.salt,
    },
    {
    payload: jsonToPayload({ body: "First reply" }),
    contentType: "application/json",
    attributes: {
    project: str("my_project"),
    type: str("reply"),
    parent: key(thread.key),
    },
    expires: ExpirationTime.fromDays(30),
    salt: reply.salt,
    },
    ],
    })
  3. Confirm the keys from the result. createdEntities holds the keys the engine minted, in batch order.

A prediction holds only while no other create from this wallet lands first, because each create consumes a counter value and moves the predicted keys.

Every write is a transaction, and every transaction from one wallet must carry the next nonce in sequence. The SDK fetches the nonce from the network at send time, so two writes in flight at once fetch the same nonce and collide. One of them is rejected, or silently replaces the other:

// Wrong: both writes fetch the same nonce
await Promise.all([
walletClient.createEntity(first),
walletClient.createEntity(second),
])

The preferred fix is one batch, because one transaction has one nonce. When the writes come from code paths you cannot join, give the account viem’s nonce manager. It allocates nonces locally and in order:

import { createWalletClient } from "@arkiv-network/sdk"
import { tiramisu } from "@arkiv-network/sdk/chains"
import { http } from "viem"
import { nonceManager, privateKeyToAccount } from "viem/accounts"
const walletClient = createWalletClient({
chain: tiramisu,
transport: http(),
account: privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`, {
nonceManager,
}),
})

With the nonce manager, the Promise.all above works. The createNonceManager docs of viem describe the options.

Every write method takes an optional second argument with the transaction fields the wallet would otherwise estimate:

await walletClient.createEntity(params, {
gas: 500_000n,
maxFeePerGas: 2_000_000_000n,
maxPriorityFeePerGas: 1_000_000_000n,
})

The fields are gas, nonce, and either gasPrice for a legacy transaction or maxFeePerGas with maxPriorityFeePerGas for an EIP-1559 one. Anything you leave out is estimated.

A write that the chain rejects throws EntityMutationError. Its message names the reason, and txHash is set when the transaction reached the chain. The SDK never retries. The reasons you are most likely to see:

ReasonMeaning
NotOwnerThe signing wallet does not own the entity
EntityNotFoundNo live entity has that key. It was deleted, or it expired
ReadOnlyEntityA patch on an entity created with readonly
ExpiryNotExtendedThe new expiry is earlier than the current one
Ident32InvalidByteAn attribute name holds an uppercase letter or another character outside the grammar

A rejected wallet confirmation, insufficient GLM for gas, and an unreachable RPC throw the same error with the transport’s message. Invalid parameters, such as a float passed to i32(), throw a typed error from the package root before anything is sent.

import { EntityMutationError } from "@arkiv-network/sdk"
import { str } from "@arkiv-network/sdk/attr"
try {
await walletClient.patchEntity({ entityKey, set: { status: str("sold") } })
} catch (error) {
if (error instanceof EntityMutationError) {
console.error(error.message, error.txHash)
} else {
throw error
}
}