arkivjs
A TypeScript client library for Arkiv's blockchains interactions. The Arkiv SDK builds strongly on the Viem library - it extends Viem's clients with Arkiv's chain-specific features.
viem is a peer dependency: install it alongside the SDK.
Installation
npm install @arkiv-network/sdk viem
# or
pnpm install @arkiv-network/sdk viem
# or
bun add @arkiv-network/sdk viem
# or
yarn add @arkiv-network/sdk viem
Usage
Below is a tutorial to help you create simple scripts that use Arkiv to query and write data.
Prerequisites
- Node.js 22.10.0 or newer (see nodejs.org), or
bun, a JavaScript/TypeScript runtime and package manager that natively supports TypeScript without transpilation. - An Arkiv access key. Arkiv's public RPC is heavily rate limited to prevent abuse, so we recommend getting a free access key at hub.arkiv.network/access-keys.
- Some testnet funds. Writing an entity is a transaction, and its gas is paid in testnet tGLM. Get some for your account's address from the faucet at hub.arkiv.network/faucet. Project Setup shows how to create an account if you don't have one.
Project Setup
Create a new directory and navigate into it:
mkdir arkiv-sample
cd arkiv-sample
Create an empty example.ts file:
touch example.ts
Initialize a new JavaScript/TypeScript project:
npm init
You can accept all the default options by pressing Enter at each prompt.
After this step, a package.json file will be created with content similar to:
{
"name": "arkiv-sample",
"version": "1.0.0",
"description": "",
"license": "ISC",
"author": "",
"type": "commonjs",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
}
}
Modify the "main" entry to "example.ts" and set "type" to "module" so your package.json looks like this:
{
"name": "arkiv-sample",
"version": "1.0.0",
"description": "",
"license": "ISC",
"author": "",
"type": "module",
"main": "example.ts",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
}
}
Install @arkiv-network/sdk along with its viem peer dependency using your preferred package manager:
npm install @arkiv-network/sdk viem
This command will update your package.json with a section like:
"dependencies": {
"@arkiv-network/sdk": "^0.9.0",
"viem": "^2.38.2"
}
It will also create a node_modules directory with all dependencies installed.
If you don't have a testnet account yet, create one with viem. This prints a new private key and its address:
node --input-type=module -e "import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; const key = generatePrivateKey(); console.log('Private key:', key); console.log('Address:', privateKeyToAccount(key).address)"
Fund the address at the faucet.
Finally, create a .env file with your access key and private key. The example reads them from there, so neither ends up in your code:
ARKIV_ACCESS_KEY=your-access-key
PRIVATE_KEY=0x-your-private-key
Define a Schema
Arkiv stores entities, and an entity is a set of named, typed attributes. You describe them once in a schema, much as you would describe a table's columns in SQL, and choose per column whether the node indexes it. Only indexed columns can be filtered on; the rest are stored and selectable, and do not pay for an index.
The examples below use this schema for blog posts. Each example declares it at the top of its own
file, so that it runs as a single file under Node.js, Bun or tsc alike:
import { addr, bytes, dec, defineSchema, str } from "@arkiv-network/sdk/schema"
// Called with no arguments, a type constructor describes a column.
const Posts = defineSchema("posts", {
author: addr().indexed(),
category: str().indexed(),
score: dec().indexed(),
title: str(), // stored, not indexed
body: bytes(), // a blob; bytes can never be indexed
thumbnail: bytes().optional(), // any number of bytes columns; this one may be absent
mimeType: str().default("text/markdown"), // written on create when left out
})
The type constructors are bool, i32, u64, u256, dec, str, addr, key and bytes.
Each column may be refined with .indexed() (every type but bytes), .optional() and
.default(...).
The same constructors, called with a value, make a typed value: dec("4.1") is a decimal, the way SQL
has DECIMAL as a type and DECIMAL '4.1' as a literal.
A schema is an SDK-side convention: the node sees only attributes, each with a type and an indexed flag. The schema is what types your writes and reads, and checks them at runtime.
The name, "posts", is optional but recommended. A named schema tags every entity it creates with
an indexed arkiv.schema attribute holding its name, and a query through it only matches entities
with that tag — the way a collection keeps one model's documents apart from another's. For that
reason arkiv.schema cannot be used as a column name.
Create an Entity
Paste the following in example.ts. It sets up a public client, which reads, and a wallet client, which signs and sends transactions, then creates a post through the schema:
import { createPublicClient, createWalletClient, ExpirationTime } from "@arkiv-network/sdk"
import { rabdi } from "@arkiv-network/sdk/chains"
import { and, eq, gt } from "@arkiv-network/sdk/query"
import { addr, bytes, dec, defineSchema, str } from "@arkiv-network/sdk/schema"
import { hexToString, http, isHex, stringToBytes } from "viem"
import { privateKeyToAccount } from "viem/accounts"
// The schema from "Define a Schema" above
const Posts = defineSchema("posts", {
author: addr().indexed(),
category: str().indexed(),
score: dec().indexed(),
title: str(),
body: bytes(),
thumbnail: bytes().optional(),
mimeType: str().default("text/markdown"),
})
// Rabdi is the Arkiv testnet
const chain = rabdi({ accessKey: process.env.ARKIV_ACCESS_KEY ?? "" })
const privateKey = process.env.PRIVATE_KEY
if (!privateKey || !isHex(privateKey)) {
throw new Error("Set PRIVATE_KEY in .env to the private key of a funded testnet account")
}
const account = privateKeyToAccount(privateKey)
const publicClient = createPublicClient({ chain, transport: http() })
const walletClient = createWalletClient({ chain, transport: http(), account })
// Create an entity. Every required column must be given; a defaulted one (mimeType) left out is
// written with its default. Values carry their type: use the constructors from
// "@arkiv-network/sdk/schema", or a bare string, boolean, number, bigint or Uint8Array where it is
// unambiguous (string -> str, Uint8Array -> bytes, ...). createEntity resolves once the
// transaction is in a block.
const { entityKey, txHash } = await walletClient.createEntity(
Posts,
{
author: addr(account.address),
category: "docs",
score: dec("4.5"),
title: "Hello from Arkiv",
body: stringToBytes("# Hello\n\nArkiv wishes you all the best!"),
},
{ expiration: ExpirationTime.fromDays(1) }, // the entity expires in a day
)
console.log("Created entity:", entityKey)
console.log("Transaction hash:", txHash)
// Change one column, leaving the rest alone
await walletClient.patchEntity(Posts, entityKey, { set: { score: dec("4.7") } })
Query It
Now read the post back. Add the following to the end of example.ts:
// Get the entity by key, read through the schema
const post = await publicClient.getEntity(entityKey, Posts)
console.log("Title:", post.title.value, "score:", post.score.value)
// Or find it with a query: select what to return, name the schema, then filter
const result = await publicClient
.select({ $key: true, title: true, score: true, body: true })
.from(Posts)
.where(and(eq("category", "docs"), gt("score", dec("4.0"))))
.ownedBy(account.address)
.limit(10)
.fetch()
for (const row of result.entities) {
console.log(row.$key, row.title.value, row.score.value, hexToString(row.body.value))
}
Selecting fields with select()
Pass nothing (or "*") to fetch everything — through a schema, every entity field and every
column — or pass an object to fetch only specific fields. The selection names entity fields and
attributes side by side, and each result is a flat row. Entity fields start with $, as they do in
where: $key, $owner, $creator, $createdAt, $updatedAt, $expiresAt and
$creationFlags. Any other name is an attribute. An attribute name cannot start with $, so the
two never collide — an attribute called owner is just owner:
// Every entity field and every column
await publicClient.select().from(Posts).where(eq("category", "docs")).fetch();
// Only the fields you need
await publicClient.select({ $key: true, title: true }).from(Posts).where(eq("category", "docs")).fetch();
Select only what you need. Every selected field is fetched over the network, so requesting data you won't use makes queries slower. Narrowing the selection keeps responses small and fast.
The result type is inferred from your selection and schema: each column is typed as the schema declares it, and reading a field you didn't select is a compile error.
const row = (
await publicClient.select({ $owner: true, score: true }).from(Posts).where(eq("category", "docs")).fetch()
).entities[0]!;
row.$owner; // ✅ Hex
row.score; // ✅ DecValue — row.score.value is "4.1"
row.title; // ❌ compile error — not selected
where is checked against the schema too. It takes the schema's indexed columns and the system
attributes $key, $owner, $creator and $expiresAt. Filtering on a column the schema does not
index, or comparing a column against a value of another type, is a compile error:
.where(eq("title", "x")) // ❌ title is not indexed
.where(eq("score", 5)) // ❌ score is a dec, 5 is an i32 — write dec(5)
.where(between("score", dec(1), 5)) // ❌ both bounds need one type
Sort with orderBy, passing one key: an asc(...) or a desc(...). Calling orderBy again
replaces the order. The node sorts the attribute as one type, so the key has a type: through a schema it is
the column's, and the system attributes $key, $owner, $creator and $expiresAt have fixed
ones. Without a schema, write it. Call orderBy after from(...), so the compiler can check the
key against the schema; a key given before it is checked only when the query is sent:
import { asc, desc } from "@arkiv-network/sdk/query"
.from(Posts).orderBy(desc("score")) // score:dec DESC
.from(Posts).orderBy(asc("title")) // title:str ASC, indexed or not
.from(Posts).orderBy(asc("$expiresAt")) // $expiresAt:u64 ASC
.where(eq("category", "docs")).orderBy(desc("score", "dec")) // without a schema
.from(Posts).orderBy(asc("body")) // ❌ bytes cannot be sorted on
.from(Posts).orderBy(desc("$createdAt")) // ❌ $createdAt cannot be sorted on
.from(Posts).orderBy(desc("score"), asc("$key")) // ❌ one key only
Any column but a bytes one can be sorted on, indexed or not. An entity where the attribute is unset, or set with
another type, has no value to sort by, and sorts below every value: first with asc, last with
desc. The order holds across all the matches, not each page on its own.
Page with limit and offset. A page holds up to 100 rows, and offset skips the matches before
it. Sort by a key that does not change, such as $key, so the pages line up:
const page3 = await publicClient
.select({ $key: true, title: true })
.from(Posts)
.orderBy(asc("$key"))
.limit(20)
.offset(40)
.fetch()
An entity created or deleted between two requests shifts the offsets after it, so a row can repeat
or be missed. To read every match exactly once while others write, page by key instead: keep the
asc("$key") sort and filter on gt("$key", key(lastKey)), where lastKey is the last $key of
the previous page. Through a schema, pass { onMismatch: "throw" }: a page of skipped entities
has no rows, so no key to continue from.
A named schema needs no where at all — select().from(Posts) matches every post. An unnamed schema types a query without
filtering it, so where alone decides which entities match.
Only what you select is checked. An entity that matches but does not fit the schema — a selected
column that is required but missing or holds another type, or, for a named schema, another schema's
tag — is left out of the page. Anyone can write an entity a query matches, so one that does not
fit never fails the read of the others. A page can then hold fewer rows than its limit, and reports
how many it left out as skipped. Pass { onMismatch: "throw" } to fail the fetch with a
SchemaMismatchError instead:
const page = await publicClient.select().from(Posts).where(eq("category", "docs")).fetch();
page.skipped; // matching entities on this page that did not fit Posts
A query reads the same without a schema — say, to explore what a node holds. Leave out
.from(...) and the selection means what it did (save "*", below), and the rows keep their
shape. Only the checking is gone, along with a named schema's tag filter: each attribute comes back
as whatever value the entity holds, or undefined, and where takes any attribute:
const row = (
await publicClient.select({ $key: true, title: true }).where(eq("category", "docs")).fetch()
).entities[0]!;
row.title; // ArkivValue | undefined — e.g. { type: "str", value: "Hello" }
To read every attribute without naming them, select $attributes, a map of all of them. "*" (or
nothing) fetches it alongside the entity fields when there is no schema, since there are no columns
to list.
$attributeSchema is for finding out what entities hold without fetching their values: each
attribute's name, its type, and whether it is indexed. Values can be large — a bytes attribute
holds up to 128 KiB — and an RPC endpoint may cap how big a response it sends, so a page of 100
entities with every attribute can be more than it will return. Fetch the schema first, then select
the attributes you need by name. It is never part of "*"; select it by name:
const page = await publicClient.select({ $key: true, $attributeSchema: true }).where(eq("category", "docs")).fetch();
page.entities[0]?.$attributeSchema; // { title: { type: "str", indexed: false }, body: { type: "bytes", indexed: false } }
Both work through a schema too.
Footgun: pass the selection inline. A selection stored in a variable widens its
truevalues toboolean, so which fields come back is only known at runtime and every field of the result is optional. If you need to reuse one, annotate itas const:const fields = { $owner: true, title: true } as const; await publicClient.select(fields).from(Posts).where(eq("category", "docs")).fetch();
Running the Example
You have several options to run your TypeScript sample. Each loads your .env file:
-
With Node.js (using experimental TypeScript support):
node --env-file=.env --experimental-strip-types example.ts -
With Bun (native TypeScript support, loads
.envon its own):bun example.ts -
Classic Node.js (using transpilation to JavaScript):
- Install TypeScript and the Node.js type definitions if you haven't already:
npm install typescript @types/node - Initialize a TypeScript config with default settings:
This will create anpx tsc --inittsconfig.jsonfile. Set"types": ["node"]in it, so thatprocess.envtype-checks. - Transpile your
.tsfiles into.js:
This creates anpx tsc --outDir distdistdirectory containingexample.js(the transpiled code), along with corresponding type declaration and source map files. - Run the transpiled script:
node --env-file=.env dist/example.js
- Install TypeScript and the Node.js type definitions if you haven't already:
Sample code can also be found in the sample directory of this repository.
Package Distribution
This package supports multiple module formats for maximum compatibility:
- ES Modules (
dist/*.js) - For modernimportstatements - CommonJS (
dist/*.cjs) - For Node.jsrequire() - Type Declarations (
dist/*.d.tsanddist/*.d.cts) - Full TypeScript support
The build uses tsdown to generate both ESM and CommonJS formats with proper type declarations.
Runtime Support
Node.js (ESM):
import { createPublicClient } from '@arkiv-network/sdk'; // Uses compiled ESM
Node.js (CommonJS):
const { createPublicClient } = require('@arkiv-network/sdk'); // Uses compiled CJS
Bun (TypeScript native):
import { createPublicClient } from '@arkiv-network/sdk'; // Uses *.ts directly
All formats provide full type safety and IntelliSense support when using TypeScript.
Development
To install dependencies:
bun install
To build all outputs (ESM, CommonJS, and type declarations):
bun run build
For more information about building this SDK refer to: BUILD.md
To run type checking:
bun run type-check
To lint:
bun run lint
For more information about refer to: CONTRIBUTING.md
Verbose Logging
Set the DEBUG environment variable (or, in a browser, the DEBUG key in localStorage) to view verbose logs:
DEBUG=arkiv:* bun run your-script
Adjust the namespace (for example, arkiv:query:* or arkiv:actions:wallet:*) to target specific log sources, and prefix one with - to exclude it. Unset DEBUG to silence debug output.