Skip to content

Mutating Entities

All entity mutations are sent as standard Ethereum transactions to a fixed Arkiv address. The transaction data field is a normal ABI-encoded call to one function, execute. That call carries one or more operations.

PropertyValue
Arkiv Address0x4400000000000000000000000000000000000044
Block Time2 seconds
Transaction Value0 (no native token transferred)

The address is a protocol precompile. It is the same on every Arkiv chain. There is no dedicated arkiv_* write method. The node reads Arkiv operations out of a normal transaction, the same way it reads other contract calls.

The transaction data is an ABI-encoded call to execute:

function execute((uint8 operation, bytes operationData)[] ops) external returns (bytes32[] keys);
FieldTypeDescription
operationuint8The operation tag. See the table below.
operationDatabytesabi.encode of the struct that the tag selects.

The function selector is 0x49650044. A transaction is an ordered, atomic batch. All operations apply, or none do. Operation n+1 sees the effects of operation n. Each applied operation emits one event.

TagOperation
1Create
2Patch
3Extend Expiry
4Transfer Ownership
5Delete

Any other tag reverts with InvalidOpType.

Each operationData is the standard ABI encoding of one struct. The structs below use Solidity tuple notation.

(uint128 salt, uint64 expiresAt, uint64 minLifetime, uint8 creationFlags, Attribute[] attributes)
FieldTypeDescription
saltuint128Random input to the entity key. 0 opts out of salting. See Entity Keys.
expiresAtuint64Absolute expiry block, or 0. See Expiry.
minLifetimeuint64Minimum lifetime in blocks, or 0. See Expiry.
creationFlagsuint8Immutable entity properties. See Creation Flags.
attributesAttribute[]The attributes, the payload, and the content type. See Attributes.

The engine derives the entity key. The transaction does not carry one.

(bytes32 entityKey, Attribute[] mutations)
FieldTypeDescription
entityKeybytes32The key of the entity to change.
mutationsAttribute[]Attributes to set, and tombstones for attributes to unset.

A patch changes only the attributes it names. Every other attribute stays as it is. A patch with an empty mutations list reverts with EmptyMutations.

(bytes32 entityKey, uint64 expiresAt, uint64 minLifetime)
FieldTypeDescription
entityKeybytes32The key of the entity to extend.
expiresAtuint64Absolute expiry block, or 0. See Expiry.
minLifetimeuint64Minimum lifetime in blocks, or 0. See Expiry.

The new expiry must be later than the current one. A shorter expiry reverts with ExpiryNotExtended.

(bytes32 entityKey, address newOwner)
FieldTypeDescription
entityKeybytes32The key of the entity to transfer.
newOwneraddressThe new owner. Not the zero address, and not the current owner.
(bytes32 entityKey)
FieldTypeDescription
entityKeybytes32The key of the entity to delete.

An attribute is a triple:

struct Attribute { bytes32 name; uint8 typeId; bytes value; }

The name is an Ident32: an ASCII string, left-aligned in 32 bytes and padded with zero bytes. The grammar is a letter, then letters, digits, ., -, or _. Names are case-sensitive. The query language reserves some words, for example and, not, str, and u64. The node rejects those as attribute names.

Names that start with $ are system attributes. You can write two of them:

NameTypeDescription
$payloadbytes (7)The entity content. Up to 128 KiB.
$contentTypestr (8)The MIME type of the payload. Lowercase only.

The wire format has no dedicated payload field. The payload is one attribute among the others. Any other $ name reverts with SystemAttributeNotWritable.

typeIdTypeValue encoding
0tombstoneZero-length. Unsets the attribute. Legal only in a patch.
1bool32 bytes: 0 or 1.
2i3232 bytes, sign-extended.
3u6432 bytes, right-aligned.
4u25632 bytes.
5dec32 bytes: the decimal as a signed integer of scaled units.
6bytes3232 bytes.
7bytesRaw bytes, variable length.
8strRaw UTF-8 bytes, variable length.
9addr32 bytes, right-aligned.
10key32 bytes: an entity key.

Word types use the encoding that Solidity uses for a single word. A word that is not exactly 32 bytes, or a bool word that is not 0 or 1, reverts with InvalidValueType.

  • Order. The list must be strictly ascending by the bytes32 name. Two attributes with the same name, or a list out of order, revert with AttributesNotSorted. The engine hashes attributes in this order into the state root.
  • Count. One operation carries at most 32 attributes. The system attributes count. More revert with TooManyAttributes.
  • Tombstones. In a patch, typeId 0 with an empty value unsets the attribute. A tombstone in a create reverts with TombstoneInCreate. A tombstone with a value reverts with TombstoneValueNotEmpty.

Create and Extend Expiry carry the same two fields. The engine resolves them to one block height:

expiry = max(expiresAt, currentBlock + minLifetime)
You wantexpiresAtminLifetime
A lifetime, for example 30 days0seconds / 2
An absolute deadlinethe block height0
A deadline, but at least a lifetimethe block heightseconds / 2

currentBlock is the block that includes the transaction. Durations convert to blocks at the nominal 2-second block time, so a lifetime in days is approximate. The resolved expiry must be after currentBlock. If it is not, the operation reverts with ExpiryDeadOnArrival.

The creationFlags byte is a bit field. The flags are fixed at creation. No operation changes them later.

BitFlagDescription
0read-onlyThe attributes and payload can never change. Only extend, transfer, and delete are allowed.
1permissionless extensionAny account can extend the expiry, not only the owner.
2 to 7reservedMust be zero. Otherwise the operation reverts with ReservedCreationFlags.

The engine derives the key of a new entity:

entityKey = keccak256(abi.encodePacked(uint256 chainId, address arkiv, address owner, uint64 nonce, uint128 salt))
InputDescription
chainIdThe chain ID.
arkivThe Arkiv address, 0x4400000000000000000000000000000000000044.
ownerThe account that sends the transaction.
nonceThe owner’s entity nonce. Each create in a batch consumes one, so the nth create uses nonce + n.
saltThe salt field of the create.

The nonce is separate from the Ethereum transaction nonce. Read it with an eth_call to the same address:

function entityNonce(address owner) external view returns (uint64);
Terminal window
curl https://rpc.tiramisu.db-chain.testnet.arkiv.network \
-H "content-type: application/json" \
-d '{
"jsonrpc":"2.0",
"id":1,
"method":"eth_call",
"params":[{
"to":"0x4400000000000000000000000000000000000044",
"data":"0x36917bfd000000000000000000000000'"${OWNER#0x}"'"
},"latest"]
}'

With the nonce, you can predict the key before the transaction lands. A later operation in the same batch can then reference the new entity. A random salt keeps the key unpredictable to anyone but the creator.

The calldata goes through three stages:

  1. Encode the attributes — Convert every name to a padded bytes32. Encode every value as its type requires. Sort the triples by name.

  2. Encode each operation — abi.encode the struct for the operation. Pair the result with its tag.

  3. Encode the call — abi.encode the execute call with the list of (operation, operationData) pairs. Prefix the selector 0x49650044.

Any ABI library does this. The example below uses viem. Foundry’s cast calldata and cast abi-encode work too.

Goal: Create an entity with payload {"message": "Hello from Arkiv"}, content type application/json, a 30-day lifetime, and two attributes: category as str and count as u64.

Four attributes, sorted by their bytes32 name. $ sorts before letters, and category sorts before count.

Namebytes32 nametypeIdvalue
$contentType0x24636f6e74656e7454797065 + zero padding80x6170706c69636174696f6e2f6a736f6e
$payload0x247061796c6f6164 + zero padding70x7b226d657373616765223a2248656c6c6f2066726f6d2041726b6976227d
category0x63617465676f7279 + zero padding80x6578616d706c65
count0x636f756e74 + zero padding30x00…0064 (32 bytes)

A 30-day lifetime is 30 * 24 * 3600 / 2 = 1296000 blocks. This example uses a fixed salt of 0x1234 so the result is reproducible. Use a random salt in production.

import { encodeAbiParameters, parseAbiParameters, stringToBytes, toHex } from "viem"
const attribute = "(bytes32 name, uint8 typeId, bytes value)[]"
const createStruct = parseAbiParameters(
`(uint128 salt, uint64 expiresAt, uint64 minLifetime, uint8 creationFlags, ${attribute} attributes)`,
)
const name = (s: string) => toHex(s, { size: 32 })
const attributes = [
{ name: name("$contentType"), typeId: 8, value: toHex(stringToBytes("application/json")) },
{ name: name("$payload"), typeId: 7, value: toHex(stringToBytes('{"message":"Hello from Arkiv"}')) },
{ name: name("category"), typeId: 8, value: toHex(stringToBytes("example")) },
{ name: name("count"), typeId: 3, value: toHex(100n, { size: 32 }) },
]
const operationData = encodeAbiParameters(createStruct, [
{ salt: 0x1234n, expiresAt: 0n, minLifetime: 1_296_000n, creationFlags: 0, attributes },
])
import { encodeFunctionData, parseAbi } from "viem"
const abi = parseAbi([
"function execute((uint8 operation, bytes operationData)[] ops) external returns (bytes32[] keys)",
])
const data = encodeFunctionData({
abi,
functionName: "execute",
args: [[{ operation: 1, operationData }]],
})

The result starts with the selector, then the offset to the ops array, then its length of 1:

0x49650044
0000000000000000000000000000000000000000000000000000000000000020
0000000000000000000000000000000000000000000000000000000000000001
...
Full calldata for this example
0x496500440000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000003e0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000012340000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000013c680000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000012000000000000000000000000000000000000000000000000000000000000001c0000000000000000000000000000000000000000000000000000000000000026024636f6e74656e745479706500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000106170706c69636174696f6e2f6a736f6e00000000000000000000000000000000247061796c6f616400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000070000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000001e7b226d657373616765223a2248656c6c6f2066726f6d2041726b6976227d000063617465676f72790000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000076578616d706c6500000000000000000000000000000000000000000000000000636f756e740000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000064

This hex string is the transaction data field.

FieldValue
to0x4400000000000000000000000000000000000044
value0
datathe ABI-encoded execute call

Sign the transaction for chain ID 7738577 with any Ethereum signer.

Terminal window
curl https://rpc.tiramisu.db-chain.testnet.arkiv.network \
-H "content-type: application/json" \
-d '{
"jsonrpc":"2.0",
"id":1,
"method":"eth_sendRawTransaction",
"params":["'"$SIGNED_TX"'"]
}'

eth_sendRawTransaction returns a transaction hash immediately. The network does not apply the mutation until a block includes the transaction.

Terminal window
curl https://rpc.tiramisu.db-chain.testnet.arkiv.network \
-H "content-type: application/json" \
-d '{
"jsonrpc":"2.0",
"id":1,
"method":"eth_getTransactionReceipt",
"params":["'"$TX_HASH"'"]
}'

The receipt’s logs contain one event per applied operation. A receipt does not carry the return value of execute, so read the entity keys from the logs, or predict them.

All five operation types can be combined in one execute call, in any order:

const data = encodeFunctionData({
abi,
functionName: "execute",
args: [[
{ operation: 1, operationData: createData },
{ operation: 2, operationData: patchData },
{ operation: 5, operationData: deleteData },
{ operation: 3, operationData: extendData },
{ operation: 4, operationData: transferData },
]],
})

The batch is atomic. If one operation reverts, the whole transaction reverts. An empty batch reverts with EmptyBatch.

The engine emits events in application order, which is the order of the operations in the batch. The SDK orders a batch as creates, patches, deletes, extensions, ownership changes. The protocol does not require that order.

The Arkiv address emits one event per applied operation:

OperationEvent
CreateEntityCreated(bytes32 indexed entityKey, address indexed owner, uint64 expiresAt, uint8 creationFlags)
PatchEntityPatched(bytes32 indexed entityKey, address indexed owner)
Extend ExpiryExpiryExtended(bytes32 indexed entityKey, address indexed owner, uint64 expiresAt)
Transfer OwnershipOwnershipTransferred(bytes32 indexed entityKey, address indexed previousOwner, address indexed newOwner)
DeleteEntityDeleted(bytes32 indexed entityKey, address indexed owner)

expiresAt in EntityCreated and ExpiryExtended is the resolved expiry block. Filter logs by the Arkiv address, because a transaction can also carry logs from other contracts.

There is no dedicated arkiv_subscribe* method. Watching these events rides on the standard eth_getLogs and eth_subscribe methods, the same way other contract events do. The SDK wraps this in watchEntityEvents(). See Live Events.

A failed operation reverts with a custom error. The revert data is ABI-encoded, so eth_call and eth_estimateGas return it.

ErrorWhen
EmptyBatch()The ops list is empty.
InvalidOpType(uint8 operation)The tag is not 1 to 5.
NonCanonicalOperationData(uint8 operation)The operationData is not the canonical ABI encoding of its struct.
AttributesNotSorted()The attribute list is not strictly ascending by name.
TooManyAttributes(uint256 count, uint256 maxCount)More than 32 attributes in one operation.
EmptyMutations(bytes32 entityKey)A patch with no mutations.
TombstoneInCreate(bytes32 name)A tombstone in a create.
TombstoneValueNotEmpty(bytes32 name)A tombstone with a value.
SystemAttributeNotWritable(bytes32 name)A $ name other than $payload or $contentType.
InvalidValueType(bytes32 name, uint8 typeId)An unknown typeId, or a value that does not fit its type.
Ident32Empty()An empty attribute name.
Ident32InvalidByte(uint256 position, bytes1 value)A byte outside the name grammar.
EntityNotFound(bytes32 entityKey)The entity does not exist.
EntityExpired(bytes32 entityKey, uint64 expiresAt)The entity has expired.
NotOwner(bytes32 entityKey, address caller, address owner)The sender does not own the entity.
ReadOnlyEntity(bytes32 entityKey)A patch on a read-only entity.
ReservedCreationFlags(uint8 creationFlags)A reserved flag bit is set.
ExpiryDeadOnArrival(uint64 target, uint64 currentBlock)The resolved expiry is not after the current block.
ExpiryNotExtended(bytes32 entityKey, uint64 newExpiresAt, uint64 currentExpiresAt)The new expiry is not later than the current one.
TransferToZeroAddress(bytes32 entityKey)Transfer to the zero address.
TransferToSelf(bytes32 entityKey)Transfer to the current owner.

These methods come from @arkiv-network/sdk. Each one is a call on a WalletArkivClient, and each call sends exactly one execute transaction. Click a method name to open its API reference:

ActionOperationWhat it does
createEntity()CreateCreates a new entity with a payload, content type, attributes, and an expiry
patchEntity()PatchSets or unsets individual attributes, or replaces the payload, leaving anything unnamed untouched
deleteEntity()DeleteDeletes an entity
extendEntity()Extend ExpiryMoves an entity’s expiry later
changeOwnership()Transfer OwnershipTransfers an entity to a new owner
executeBatch()BatchApplies a combination of the above atomically in one transaction

See Mutating Data for usage examples of each.