---
name: arkiv-crosschain
description: Cross-chain architecture for Arkiv, the queryable database on Ethereum. When a dapp spans Arkiv plus a value chain (Ethereum, an L2 like Scroll or Arbitrum, or Stellar), this skill decides what lives where. Covers the partition rule (tokens/contracts/proofs on the value chain, high-volume queryable state on Arkiv), the mirror-entity convention (sourceChainId/contractAddress/tokenOrId/txHash attributes plus entityKey pointers stored in contracts), two-tier durability (expiring Arkiv state plus a permanent credential NFT/POAP elsewhere), a reorg-safe self-pruning chain-data indexer (getLogs into expiring entities), and micropayment-gated access (x402, experimental). Activate when the user mentions Arkiv or @arkiv-network/sdk together with cross-chain, multichain, bridge, NFT metadata, dynamic NFT, POAP, indexer, event indexing, getLogs, mirror entities, storing an entityKey in a contract, Scroll, Stellar, Soroban, or "which data goes on which chain".
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, defineChain, parseAbiItem, privateKeyToAccount, braga, eq, gte, lte, jsonToPayload, ExpirationTime, createEntity, updateEntity, mutateEntities, buildQuery, createdBy, getEntity, fetch, hasNextPage, next, toJson, NoEntityFoundError"
  arkiv.last-verified-at: "2026-06-11"
  arkiv.evidence: "5 patterns from 124 hackathon submissions across 5 events"
---

# arkiv-crosschain — Arkiv as the queryable data layer NEXT TO your value chain

Use this skill when an app touches Arkiv **and** another chain. Arkiv does not replace your token, NFT, or ZK-verifier contracts — it sits beside them as the queryable database on Ethereum that holds the high-volume, filterable, expiring state those chains are terrible at. This skill makes the split decision for you, gives the cross-reference conventions that keep the two sides joined, and ships a reorg-safe indexer worker for mirroring contract events into entities.

## Ground rules

> **Networks rotate. Verify at https://docs.arkiv.network before relying on these. Testnet entities do NOT migrate between networks.** (8 of 124 surveyed submissions are stranded on dead testnets — Kaolin and Mendoza are both sunset.)
>
> - Arkiv testnet: **Braga** — chainId `60138453102`, gas token **GLM** (not ETH)
> - RPC `https://braga.hoodi.arkiv.network/rpc` · WS `wss://braga.hoodi.arkiv.network/rpc/ws`
> - Faucet `https://braga.hoodi.arkiv.network/faucet/` · Explorer `https://explorer.braga.hoodi.arkiv.network`
> - SDK: `@arkiv-network/sdk` `>=0.6.5 <0.7.0` (this skill verified against **0.6.8** source)
> - **Pin Node 22 LTS.** Node 24 hangs forever on entity updates — the tx lands on-chain but the promise never resolves (sdk issue #14).
> - Externalize ALL chain config (both chains) behind env vars. Hardcoded networks are why most surveyed demos are dead today.
> - Fund the indexer/server wallet from the faucet. A plain native transfer INTO Braga is rejected with `"non-golembase transaction"` — GLM arrives via the faucet or the official bridge, never a raw send.

## Shared setup (every snippet below assumes this)

```ts
import { createPublicClient, createWalletClient, http, defineChain, parseAbiItem } from "@arkiv-network/sdk";
import { privateKeyToAccount } from "@arkiv-network/sdk/accounts";
import { braga } from "@arkiv-network/sdk/chains";
import { eq, gte, lte } from "@arkiv-network/sdk/query";
import { jsonToPayload, ExpirationTime } from "@arkiv-network/sdk/utils";

const NS = "myapp-crosschain-v1"; // project namespace — stamp on EVERY write AND query
const INDEXER_ADDRESS = "0xYourIndexerWallet" as `0x${string}`; // = arkivWrite's address — pin reads to it

// Arkiv always gets TWO clients — the wallet client has NO read actions.
const arkivRead = createPublicClient({ chain: braga, transport: http() });
const arkivWrite = createWalletClient({
  chain: braga,
  transport: http(),
  account: privateKeyToAccount(process.env.ARKIV_PRIVATE_KEY as `0x${string}`),
});
```

The SDK re-exports **all of viem** (`http`, `defineChain`, `parseAbiItem`, accounts) — do NOT `npm install viem` separately; you will get version-mismatch bugs against the SDK's pinned `viem ^2.38.2`.

---

### partition-rule

**Decide this FIRST, before any schema work.** The dominant cross-chain architecture in the corpus: keep value and enforcement on the chain that has them; keep everything you query on Arkiv.

| Goes on the value chain (L1/L2/Stellar) | Goes on Arkiv (Braga) |
|---|---|
| Moves tokens or value | App state you filter, search, or list |
| Needs contract-enforced rules | High-volume mutable records (feeds, telemetry, readings) |
| ZK proof verification | Profiles, metadata, sessions, coordination state |
| Permanent credentials (NFT / SBT / POAP) | Anything with a natural expiration date |
| Identity registries (ERC-8004 etc.) | Derived / indexed views of the other chain's data |

Default: **one wallet role per chain.** The user's wallet signs value-chain txs; YOUR server wallet signs Arkiv writes (see `arkiv-app-architecture` for the signer spectrum). Never reuse one private key as both the Arkiv signer and the contract-chain signer — RIKUY did, creating one custodial hot key across two chains.

Worked split (ArkAnon's shape — ZK verified on Scroll, Arkiv serves the feed):

```ts
// 1. The user's tx on the value chain proves the claim (ZK membership, payment, mint).
// 2. Your server then indexes the accepted result into Arkiv for cheap, filterable reads.
const { entityKey, txHash } = await arkivWrite.createEntity({
  payload: jsonToPayload({ message, postedAt: Date.now() }),
  contentType: "application/json",
  attributes: [
    { key: "project", value: NS },
    { key: "entityType", value: "signal" },
    { key: "sourceChainId", value: 534351 },                   // Scroll Sepolia — number, range-queryable
    { key: "contractAddress", value: VERIFIER.toLowerCase() }, // scope every entity to ONE deployment
    { key: "nullifier", value: nullifierHash },                // double-post check = one eq() query
  ],
  expiresIn: ExpirationTime.fromDays(30),
});

// 3. The UI feed is ONE Arkiv query — no contract reads, no event scraping in the client:
const feed = await arkivRead.buildQuery()
  .where([eq("project", NS), eq("entityType", "signal"),
          eq("contractAddress", VERIFIER.toLowerCase())])
  .withPayload(true).withAttributes(true)   // builder defaults are EMPTY — always request data
  .limit(100)                                // pagination requires .limit()
  .fetch();
const posts = feed.entities.map((e) => ({ key: e.key, ...e.toJson() }));
posts.sort((a, b) => b.postedAt - a.postedAt); // sort in JS — the server ignores orderBy (removed in 0.7.0)
```

Scoping queries by `contractAddress` means a contract redeploy never pollutes results with stale-deployment entities.

Seen in 18 of 124 submissions — Ark Hive (ERC-8004 agent identity on Sepolia, all sessions on Braga), Arkivox, AdChain, PayFlow, TruthStamp, ArkAnon (winner, Best Real-Time, ETH Argentina: Scroll verifies the proofs, Arkiv serves the feed).

---

### mirror-entity-convention

How the two chains stay joined. One mirror entity per on-chain object (NFT, position, registration, invoice), with a fixed attribute set; the other chain stores the `entityKey` back.

```ts
// Arkiv side — the queryable mirror:
const { entityKey } = await arkivWrite.createEntity({
  payload: jsonToPayload({ name, image, description, lastSyncedAt: Date.now() }),
  contentType: "application/json",
  attributes: [
    { key: "project", value: NS },
    { key: "entityType", value: "nftMirror" },
    { key: "sourceChainId", value: 421614 },                       // number
    { key: "contractAddress", value: NFT_ADDRESS.toLowerCase() },  // ALWAYS lowercase addresses at write time
    { key: "tokenOrId", value: String(tokenId) },                  // STRING — uint256 overflows a JS number,
                                                                   // and bigint attribute values are silently dropped
    { key: "txHash", value: mintTx.toLowerCase() },                // the REAL source tx — never fake this field
  ],
  expiresIn: ExpirationTime.fromDays(90),
});
// Contract side — close the loop: store entityKey in an event arg or a contract field.
// PayFlow stores it as a string field (arkivProfileHash / arkivDataHash) and emits it in events.
```

**Write-order default (decide, don't debate):**
- Contract stores the `entityKey` → **Arkiv first**, then the contract tx carries the returned key (PayFlow).
- Contract doesn't reference Arkiv → **value-chain tx first**, mirror into Arkiv second and **non-fatally**: wrap the Arkiv write in its own try/catch and degrade with a warning ("created on-chain, metadata not indexed yet") instead of failing the user flow (ArkAnon's graceful-degradation toast).

**No tx hash at write time?** Derive a deterministic content hash and store it on BOTH sides: RIKUY stores `sha256(content)` as an Arkiv attribute AND as `bytes32` in its Scroll contract — a join key that exists before any tx. Name it honestly (`contentHash`); RIKUY called theirs `_arkivTxId` even though it is NOT a tx hash, and the misnomer confused everyone reading the code.

**Dereferencing a pointer that came from the other chain:**

```ts
import { NoEntityFoundError } from "@arkiv-network/sdk";
try {
  const entity = await arkivRead.getEntity(entityKeyFromContract); // THROWS, never returns null
  render(entity.toJson());
} catch (err) {
  if (err instanceof NoEntityFoundError) {
    // The contract pointer lives forever; the Arkiv entity expires.
    // In cross-chain designs this is a NORMAL state — render "expired/archived", don't crash.
    renderExpiredPlaceholder();
  } else throw err;
}
```

Code-verified in 3 of 124 submissions' repos — PayFlow (entityKey stored in the Scroll contract), ArkAnon (winner — chainId + contractAddress deployment scoping), RIKUY (3rd place, Arkiv track — content-derived join key). The convention is the cross-reference rule inside the 18 partition-rule projects.

---

### two-tier-durability

The canonical answer to "what happens when my data expires?". Arkiv has **no permanence primitive** — a huge expiration is a workaround, not a guarantee, and owner-delete still exists. So split durability across chains: coordination state lives on Arkiv with an honest expiration; the keepsake credential is minted on a chain that has a permanence primitive (an NFT/SBT/POAP on an L1/L2).

```ts
// Tier 1 — coordination state on Arkiv; expires when the business window closes.
const { entityKey } = await arkivWrite.createEntity({
  payload: jsonToPayload({ attendee, eventId, checkedInAt: Date.now() }),
  contentType: "application/json",
  attributes: [
    { key: "project", value: NS },
    { key: "entityType", value: "attendance" },
    { key: "eventId", value: eventId },
    { key: "attendee", value: attendee.toLowerCase() },
  ],
  expiresIn: ExpirationTime.fromDays(30), // dispute window — then it's gone, by design
});

// Tier 2 — the permanent credential elsewhere (Gath3r: soulbound POAP on Arbitrum Sepolia).
// Mint ONLY after verifying live Arkiv state (the entity still exists = the claim is current).
// Embed entityKey + a sha256 of the entity payload in the token metadata, so the credential
// stays independently verifiable AFTER the Arkiv entity expires.
```

Never describe the Arkiv side as permanent. If a record must outlive its expiration, either extend it on use (`extendEntity` ADDS seconds — see `arkiv-expiration-design`) or promote it to the credential tier before it lapses.

Seen in 5 of 124 submissions — Gath3r (POAP outlives the 30-day attendance entities), HAVEN, SealVault, Arbok.

---

### self-pruning-chain-indexer

Mirror another chain's contract events into Arkiv entities so agents and dashboards hit Arkiv's query API instead of raw RPC — and the index prunes itself, because the expiration date IS the retention policy. Builders arrive expecting The Graph (ArkAnon's contract docstring literally says "indexed by Arkiv", and the team hallucinated an `npx arkiv dev` indexer CLI that doesn't exist) — this worker is that recipe.

```ts
const SOURCE_CHAIN_ID = 11155111;              // wherever the contract lives (Sepolia here)
const CONTRACT = process.env.SOURCE_CONTRACT as `0x${string}`;
const DEPLOY_BLOCK = 6_500_000n;               // backfill starts here on first run
const CONFIRMATIONS = 12n;                     // reorg buffer — NEVER index the live edge
const CHUNK = 1000n;                           // getLogs block-range cap (RPC-dependent)
const RETENTION = ExpirationTime.fromDays(30); // expiration = retention policy; no cleanup job ever

// Source-chain client. Only call standard viem actions (getLogs, getBlockNumber) on it —
// Arkiv actions only work against an Arkiv RPC.
const source = createPublicClient({
  chain: defineChain({
    id: SOURCE_CHAIN_ID, name: "source",
    nativeCurrency: { name: "ETH", symbol: "ETH", decimals: 18 },
    rpcUrls: { default: { http: [process.env.SOURCE_RPC_URL!] } },
  }),
  transport: http(process.env.SOURCE_RPC_URL!),
});

// log.args carry bigints, and JSON.stringify THROWS on bigint — make payloads JSON-safe:
const jsonSafe = (v: unknown) =>
  JSON.parse(JSON.stringify(v, (_k, x) => (typeof x === "bigint" ? x.toString() : x)));

// ── Checkpoint entity: the backfill→live handoff lives ON Arkiv, so restarts resume cleanly.
let checkpointKey: `0x${string}` | undefined;

async function readCheckpoint(): Promise<bigint> {
  const r = await arkivRead.buildQuery()         // fresh builder per query — fetch() mutates it
    .where([eq("project", NS), eq("entityType", "checkpoint")])
    .createdBy(INDEXER_ADDRESS) // a spoofed checkpoint with a high nextBlock would silently skip history
    .withPayload(true).withAttributes(true)
    .limit(1)
    .fetch();
  if (r.entities.length === 0) return DEPLOY_BLOCK;
  checkpointKey = r.entities[0].key;
  return BigInt(r.entities[0].toJson().nextBlock);
}

async function writeCheckpoint(nextBlock: bigint) {
  const fields = {
    payload: jsonToPayload({ nextBlock: nextBlock.toString() }),
    contentType: "application/json",
    attributes: [{ key: "project", value: NS }, { key: "entityType", value: "checkpoint" }],
    expiresIn: ExpirationTime.fromDays(365),     // the checkpoint must outlive the data it indexes
  };
  if (!checkpointKey) {
    ({ entityKey: checkpointKey } = await arkivWrite.createEntity(fields));
  } else {
    // updateEntity is FULL REPLACE — every field re-sent; expiration restarts at 365d (a feature here).
    await arkivWrite.updateEntity({ entityKey: checkpointKey, ...fields });
  }
}

// ── The worker. Backfill and live tailing are the SAME loop.
export async function tick() {
  const head = await source.getBlockNumber();
  const safeHead = head - CONFIRMATIONS;
  let from = await readCheckpoint();

  while (from <= safeHead) {
    const to = from + CHUNK - 1n > safeHead ? safeHead : from + CHUNK - 1n;

    const logs = await source.getLogs({
      address: CONTRACT,
      event: parseAbiItem("event Transfer(address indexed from, address indexed to, uint256 indexed tokenId)"),
      fromBlock: from,
      toBlock: to,
    });

    // Crash-safe dedup: the checkpoint advances AFTER the write, so a crash in between replays
    // this window. ONE range query rebuilds the already-written set (not N point lookups).
    const seen = new Set<string>();
    const page = await arkivRead.buildQuery()
      .where([eq("project", NS), eq("entityType", "chainEvent"),
              gte("blockNumber", Number(from)), lte("blockNumber", Number(to))])
      .withAttributes(true)
      .limit(200)                                 // max page size is 200; .next() needs .limit()
      .fetch();
    const collect = () => page.entities.forEach((e) =>
      seen.add(String(e.attributes.find((a) => a.key === "dedup")?.value)));
    collect();
    while (page.hasNextPage()) { await page.next(); collect(); }

    const creates = logs
      .filter((l) => !seen.has(`${SOURCE_CHAIN_ID}:${l.transactionHash}:${l.logIndex}`))
      .map((l) => ({
        payload: jsonToPayload({ args: jsonSafe(l.args), blockHash: l.blockHash }),
        contentType: "application/json",
        attributes: [
          { key: "project", value: NS },
          { key: "entityType", value: "chainEvent" },
          { key: "dedup", value: `${SOURCE_CHAIN_ID}:${l.transactionHash}:${l.logIndex}` },
          { key: "sourceChainId", value: SOURCE_CHAIN_ID },
          { key: "contractAddress", value: CONTRACT.toLowerCase() },
          { key: "eventName", value: "Transfer" },
          { key: "txHash", value: l.transactionHash.toLowerCase() },
          { key: "logIndex", value: l.logIndex },                 // number — always fits
          { key: "blockNumber", value: Number(l.blockNumber) },   // numeric → gte/lte range scans
        ],
        expiresIn: RETENTION,
      }));

    if (creates.length > 0) {
      // ONE atomic tx per window — no nonce races. mutateEntities caps at 1000 ops/tx
      // (TOTAL across creates+updates+deletes), so if a window can yield >1000 logs,
      // chunk `creates` into ≤1000-op slices. This 1000-op WRITE cap is distinct from
      // the getLogs block-range CHUNK above (how many blocks you READ per RPC call).
      for (let i = 0; i < creates.length; i += 1000)
        await arkivWrite.mutateEntities({ creates: creates.slice(i, i + 1000) });
    }
    await writeCheckpoint(to + 1n);
    from = to + 1n;
  }
}
// Run: setInterval(tick, 15_000). ONE instance, ONE wallet — two instances sharing a wallet
// collide on nonces (see arkiv-write-safety for the write-queue rule).
```

**Reorg note (read this):** entities are not reorg-aware — once written, an event that later gets reorged out of the source chain stays in Arkiv until it expires (or you delete it). The `CONFIRMATIONS` buffer is the compensation: only index blocks deep enough that reorgs are practically over (~12 for Ethereum-family L1s; raise it for chains with weaker finality). Do not index the live edge and "fix it later" — there is no fixer.

**Consumers of the index** must filter by creator, because namespaces are organization, NOT auth — anyone can write entities carrying your `project` value on a shared testnet:

```ts
const events = await arkivRead.buildQuery()
  .where([eq("project", NS), eq("entityType", "chainEvent"), gte("blockNumber", 6_600_000)])
  .createdBy(INDEXER_ADDRESS)   // reject injected entities — only trust your indexer wallet
  .withPayload(true).withAttributes(true)
  .limit(200)
  .fetch();
```

Seen in 2 of 124 submissions — Spuro (Polkadot→Arkiv mirror with auto-expiry), DecentralIndex; demand signal in a third (ArkAnon's "indexed by Arkiv" mental model).

---

### micropayment-gated-access (x402) — experimental

Turn an Arkiv dataset into a metered API: an HTTP-402 paywall in front of a query endpoint, so agents pay per request as part of plain HTTP ("payments feel like just HTTP"). One datapoint in the corpus — treat as a frontier idea to demo, not a hardened pattern.

```ts
// Sketch (paywall middleware is the x402 library of your choice — pseudo-code):
app.get("/data", paywall402({ price: "0.001 USDC" }), async (_req, res) => {
  const r = await arkivRead.buildQuery()
    .where([eq("project", NS), eq("entityType", "reading")])
    .withPayload(true).withAttributes(true)
    .limit(200)
    .fetch();
  res.json(r.entities.map((e) => e.toJson()));
});
// Price per-query (simple) over per-entity (metering complexity you don't need at a hackathon).
```

Seen in 1 of 124 submissions — Spuro/HTTPayer. Experimental — single datapoint.

---

## Payment-chain pairings seen in the corpus

Three LATAM teams paired Arkiv with **Stellar** for the money lane — same partition rule, different value chain:

- **FerIA Salta** — fair registrations/certificates as Arkiv entities; payments as XLM inscriptions.
- **PAWSI** — vaccine records on Arkiv (expiry drives re-vaccination alerts); Soroban microdonations.
- **TruthStamp** — dual anchor: the Arkiv payload stores the Stellar tx hash; verification falls back Arkiv→Stellar.

Default for a payments combo: keep the payment receipt's tx hash as an Arkiv attribute (eq-queryable), and treat the payment chain as tier-2 durability for anything that must outlive expiration.

## Anti-patterns & gotchas (numbers = SDK survey gotcha IDs)

- **(9) Pointer outlives the data.** Every cross-chain `entityKey` dereference must catch `NoEntityFoundError` — the contract pointer is forever, the entity expires. Expired ≠ bug.
- **(3) No server-side ordering.** The server silently ignores ordering requests, and the API is removed in SDK 0.7.0 (PR #70). Fetch, then sort in JS. Never call orderBy in code.
- **(4) Pagination needs `.limit(n)`** — `result.next()` throws `NoCursorOrLimitError` without it. Max page size 200. `.count()` is broken (returns one page's length): count via pagination or marker entities.
- **(7)(8) Builder hygiene.** Data fields default EMPTY (`.withPayload(true).withAttributes(true)` or `toJson()` throws), and `fetch()` mutates the builder — build a fresh `buildQuery()` per logical query.
- **(17) BigInt poisons JSON.** `log.args` values and `getBlockTiming().currentBlock` are bigints; `JSON.stringify` throws on them. Route anything chain-derived through a bigint→string serializer before payloads/logs.
- **(15) Attribute values are `string | number` only.** bigint/boolean are **silently dropped** at encode time. uint256 ids → `String(tokenId)`; booleans → `1` = true / OMIT the attribute for false (numeric `0` encodes as `""` on the wire — don't rely on `eq(key, 0)`); block numbers/timestamps → number (epoch ms).
- **(10) Quote injection.** Interpolated string values are quoted but not escaped — a `"` in user data alters the query. Sanitize anything user-supplied: `const safe = (s: string) => s.replace(/["\\]/g, "")`.
- **(16) Reverts are opaque.** Failed Arkiv writes surface as `"No reason provided by backend."` — check the tx on the explorer, don't parse the error message.
- **(2) Seconds, not blocks.** `expiresIn` is SECONDS (use `ExpirationTime.fromDays(...)`); `expiresIn: 0` reverts on-node (`ZeroBtl`); `extendEntity` ADDS seconds.
- **(1) Full-replace updates.** `updateEntity` requires ALL fields and restarts the expiration — read → merge → write (see `arkiv-write-safety`).
- **(13) One viem.** The SDK re-exports all of viem — never install viem separately.
- **(6) Node 22 LTS**, never 24 (update promises hang; issue #14).
- **(18) Dead chains.** Only `braga` is live; `kaolin` is deprecated/sunset and `mendoza`/`marketplace`/`rosario` imports fail to compile on ≥0.6.7. Resurrecting old hackathon code = re-point chain + RPC + refund the wallet; old entities are gone.
- **(19) Isolate the Arkiv write path** in one module. Today writes are brotli-RLP calldata to the magic address `0x…61726b6976`; draft PR #64 replaces that whole mechanism with a registry contract — a contained write module migrates in one file.
- **One key, one chain.** Don't reuse the Arkiv signer key as the contract-chain signer (RIKUY's single custodial hot key across two chains). Separate env vars, separate wallets.
- **Honest naming.** A `txHash` attribute must hold a real tx hash; derived join keys are `contentHash`. Misnamed cross-chain fields (RIKUY's `_arkivTxId`) are a documented confusion source.

## Compose with

- **arkiv-data-modeling** — entity schema, namespace + creator-filter discipline, attributes-vs-payload split.
- **arkiv-expiration-design** — retention matrices, extend-on-use, two-tier durations, the no-permanence honesty rules.
- **arkiv-query-patterns** — raw query syntax, cursor pagination recipes, sanitization helper.
- **arkiv-write-safety** — mutateEntities sharp edges, write queues/nonces, the full error catalog.
- **arkiv-app-architecture** — signer spectrum (who holds which chain's key), Next.js server-signing routes, deploy checklist.
- **arkiv-realtime** — polling the mirrored feed for live UIs (subscribe vs poll vs SSE relay).
