UNPKG

@arkiv-network/sdk

Version:

TypeScript client library for Arkiv's blockchains interactions

983 lines (881 loc) • 38.5 kB
import { describe, expect, it, vi } from "bun:test" import { type Abi, BaseError, ContractFunctionRevertedError, decodeAbiParameters, encodeAbiParameters, encodeEventTopics, getAbiItem, type Hex, parseAbiParameters, toHex, } from "viem" import type { ArkivClient } from "../clients/baseClient" import { ARKIV_ADDRESS, MAX_LIFETIME, MAX_TRANSACTION_GAS } from "../consts" import { InvalidExpiryError, NO_SALT } from "../entity" import { ENTITY_EVENTS_ABI, type EntityEventName } from "../entity/events" import { MAX_EXPIRES_AT, OperationType } from "../entity/params" import { EmptyPatchError, EntityMutationError } from "../errors" import { addr, bytes, ConflictingMutationError, dec, defineSchema, EntityTooLargeError, InvalidAttributeNameError, InvalidValueError, i32, MAX_BYTES_LENGTH, MAX_ENTITY_BYTES, MissingValueError, SchemaMismatchError, str, u256, } from "../schema" import { sendArkivTransaction } from "./arkivTransactions" import { ExpirationTime } from "./expirationTime" const ENTITY_KEY = `0x${"ab".repeat(32)}` as const const OWNER = "0x1111111111111111111111111111111111111111" as const const CURRENT_BLOCK = 100n const OWNER_NONCE = 7n const CREATE_PARAMS = parseAbiParameters( "(uint128 salt, uint64 expiresAt, uint64 minLifetime, uint8 creationFlags, (bytes32 name, uint8 typeId, bytes value)[] attributes)", ) const EXTEND_PARAMS = parseAbiParameters( "(bytes32 entityKey, uint64 expiresAt, uint64 minLifetime)", ) const PATCH_PARAMS = parseAbiParameters( "(bytes32 entityKey, (bytes32 name, uint8 typeId, bytes value)[] mutations)", ) const mintedKey = (index: number) => `0x${(index + 1).toString(16).padStart(2, "0").repeat(32)}` as Hex /** What the engine stores: `max(expiresAt, currentBlock + minLifetime)`, resolved at execution. */ const engineExpiry = (expiresAt: bigint, minLifetime: bigint) => expiresAt > CURRENT_BLOCK + minLifetime ? expiresAt : CURRENT_BLOCK + minLifetime /** One receipt log, built from the same ABI the SDK reads it back with. */ function entityLog(eventName: EntityEventName, args: Record<string, unknown>) { const item = getAbiItem({ abi: ENTITY_EVENTS_ABI as Abi, name: eventName }) if (item === undefined || item.type !== "event") throw new Error(`no event ${eventName}`) const unindexed = item.inputs.filter((input) => !("indexed" in input && input.indexed)) return { address: ARKIV_ADDRESS, // biome-ignore lint/suspicious/noExplicitAny: the arg shape varies per event by design. topics: encodeEventTopics({ abi: ENTITY_EVENTS_ABI, eventName, args: args as any }), data: unindexed.length === 0 ? "0x" : encodeAbiParameters( unindexed, unindexed.map((input) => args[input.name ?? ""]), ), } } /** * The events the engine would emit for what was actually put on the wire: one `EntityCreated` per * create and one `ExpiryExtended` per extension, each carrying the expiry the engine resolved * rather than the one the SDK asked for. */ function emittedLogs(writeContract: ReturnType<typeof vi.fn>) { const logs: ReturnType<typeof entityLog>[] = [] let createIndex = 0 for (const op of sentOperations(writeContract)) { if (op.operation === OperationType.Create) { const create = decodeAbiParameters(CREATE_PARAMS, op.operationData)[0] logs.push( entityLog("EntityCreated", { entityKey: mintedKey(createIndex++), owner: OWNER, expiresAt: engineExpiry(create.expiresAt, create.minLifetime), creationFlags: create.creationFlags, }), ) } else if (op.operation === OperationType.ExtendExpiry) { const extend = decodeAbiParameters(EXTEND_PARAMS, op.operationData)[0] logs.push( entityLog("ExpiryExtended", { entityKey: extend.entityKey, owner: OWNER, expiresAt: engineExpiry(extend.expiresAt, extend.minLifetime), }), ) } } return logs } function makeClient() { const writeContract = vi.fn().mockResolvedValue("0xdeadbeef") const waitForTransactionReceipt = vi.fn(async () => ({ status: "success", transactionHash: "0xdeadbeef", logs: emittedLogs(writeContract), })) const readContract = vi.fn().mockResolvedValue(OWNER_NONCE) const estimateGas = vi.fn().mockResolvedValue(100_000n) return { client: { account: { address: OWNER }, chain: { id: 1 }, getBlockNumber: vi.fn().mockResolvedValue(CURRENT_BLOCK), estimateGas, readContract, writeContract, waitForTransactionReceipt, } as unknown as ArkivClient, estimateGas, writeContract, waitForTransactionReceipt, readContract, } } /** The operations a call put on the wire. */ function sentOperations(writeContract: ReturnType<typeof vi.fn>) { return writeContract.mock.calls[0][0].args[0] as { operation: number operationData: `0x${string}` }[] } /** The decoded Create struct of the first operation. */ function sentCreate(writeContract: ReturnType<typeof vi.fn>) { const [op] = sentOperations(writeContract) expect(op.operation).toBe(OperationType.Create) return decodeAbiParameters(CREATE_PARAMS, op.operationData)[0] } /** The decoded ExtendExpiry struct of the batch's only extension. */ function sentExtension(writeContract: ReturnType<typeof vi.fn>) { const op = sentOperations(writeContract).find((o) => o.operation === OperationType.ExtendExpiry) if (!op) throw new Error("no ExtendExpiry operation was sent") return decodeAbiParameters(EXTEND_PARAMS, op.operationData)[0] } /** The typeId bit that asks the node to index an attribute. */ const INDEXED = 0x80 const Docs = defineSchema({ category: str().indexed(), level: i32().indexed().optional(), title: str().optional(), }) /** A minimal valid create's schema and values, without its expiry. */ const CONTENTS = { schema: Docs, values: { category: "docs" } } const DAY = ExpirationTime.fromDays(1) // 86400s -> 43200 blocks at 2s const validCreate = { ...CONTENTS, expiration: DAY } const name = (n: string) => toHex(n, { size: 32 }) describe("create expiry", () => { it("sends a create for a valid expiry", async () => { const { client, writeContract } = makeClient() const result = await sendArkivTransaction(client, { creates: [validCreate] }) expect(writeContract).toHaveBeenCalledTimes(1) expect(result.receipt.status).toBe("success") }) it("puts the expiry pair on the wire untouched, for the engine to resolve", async () => { const { client, writeContract } = makeClient() await sendArkivTransaction(client, { creates: [ { ...CONTENTS, expiration: ExpirationTime.atBlock(5_000n, { atLeast: ExpirationTime.fromSeconds(200) }), }, ], }) const create = sentCreate(writeContract) expect(create.expiresAt).toBe(5_000n) expect(create.minLifetime).toBe(100n) // 200s at 2s/block }) it("reports the block the entity will actually expire at", async () => { const { client } = makeClient() const relative = await sendArkivTransaction(client, { creates: [{ ...CONTENTS, expiration: DAY }], }) expect(relative.createdExpiries[0]).toBe(CURRENT_BLOCK + 43_200n) const { client: client2 } = makeClient() const absolute = await sendArkivTransaction(client2, { creates: [{ ...CONTENTS, expiration: ExpirationTime.atBlock(5_000n) }], }) expect(absolute.createdExpiries[0]).toBe(5_000n) const { client: client3 } = makeClient() const dated = await sendArkivTransaction(client3, { creates: [ { ...CONTENTS, expiration: ExpirationTime.atDate(new Date(Date.now() + 3600_000)) }, ], }) // An hour at 2s/block, counted from the block the transaction was built on. expect(dated.createdExpiries[0]).toBe(CURRENT_BLOCK + 1800n) }) it("refuses a create with no expiry at all", async () => { const { client } = makeClient() await expect( sendArkivTransaction(client, { creates: [{ ...CONTENTS } as never] }), ).rejects.toThrow(InvalidExpiryError) }) it("catches a dead-on-arrival expiry before it costs gas", async () => { const { client, writeContract } = makeClient() await expect( sendArkivTransaction(client, { creates: [{ ...CONTENTS, expiration: ExpirationTime.atBlock(CURRENT_BLOCK - 1n) }], }), ).rejects.toThrow(/dead on arrival/) expect(writeContract).not.toHaveBeenCalled() }) it("rejects a malformed duration at the call site, long before the transaction", () => { // 999 seconds is 499.5 blocks, which is not a lifetime the chain can store. Building the value // throws, so nothing malformed ever reaches sendArkivTransaction. expect(() => ExpirationTime.fromSeconds(999)).toThrow(/multiple of the 2s block time/) expect(() => ExpirationTime.fromSeconds(0)).toThrow(InvalidExpiryError) expect(() => ExpirationTime.fromSeconds(1.5)).toThrow(/positive whole number of seconds/) }) }) describe("extend expiry", () => { it("resolves an extension exactly like a create, both forms", async () => { const { client, writeContract } = makeClient() const relative = await sendArkivTransaction(client, { extensions: [{ entityKey: ENTITY_KEY, expiration: DAY }], }) const extension = sentExtension(writeContract) expect(extension.entityKey).toBe(ENTITY_KEY) // A duration is a floor measured from now, not an increment on the entity's current expiry. expect(extension.minLifetime).toBe(43_200n) expect(extension.expiresAt).toBe(0n) expect(relative.extendedExpiries[0]).toBe(CURRENT_BLOCK + 43_200n) const { client: client2, writeContract: writeContract2 } = makeClient() const absolute = await sendArkivTransaction(client2, { extensions: [{ entityKey: ENTITY_KEY, expiration: ExpirationTime.atBlock(5_000n) }], }) expect(sentExtension(writeContract2).expiresAt).toBe(5_000n) expect(absolute.extendedExpiries[0]).toBe(5_000n) }) it("checks the maximum lifetime before spending gas, as a create does", async () => { const { client, writeContract } = makeClient() for (const tooFar of [ ExpirationTime.fromBlocks(Number(MAX_LIFETIME) + 1), ExpirationTime.atBlock(MAX_EXPIRES_AT + 1n), ]) { await expect( sendArkivTransaction(client, { extensions: [{ entityKey: ENTITY_KEY, expiration: tooFar }], }), ).rejects.toThrow(/maximum lifetime/) } expect(writeContract).not.toHaveBeenCalled() }) it("refuses an extension that says nothing", async () => { const { client } = makeClient() await expect( sendArkivTransaction(client, { extensions: [{ entityKey: ENTITY_KEY } as never] }), ).rejects.toThrow(/must be built with ExpirationTime/) }) it("reads the block height for an extension-only batch", async () => { const { client, writeContract } = makeClient() await sendArkivTransaction(client, { extensions: [ { entityKey: ENTITY_KEY, expiration: ExpirationTime.atDate(new Date(Date.now() + 3600_000)), }, ], }) // A Date can only be placed once the current block is known, so it must have been fetched even // though the batch creates nothing. expect(sentExtension(writeContract).expiresAt).toBe(CURRENT_BLOCK + 1800n) }) }) describe("creation flags and salt", () => { it("packs flags into the creationFlags byte", async () => { const { client, writeContract } = makeClient() await sendArkivTransaction(client, { creates: [{ ...validCreate, flags: { readonly: true, permissionlessExtension: true } }], }) expect(sentCreate(writeContract).creationFlags).toBe(0b11) }) it("defaults flags to none", async () => { const { client, writeContract } = makeClient() await sendArkivTransaction(client, { creates: [validCreate] }) expect(sentCreate(writeContract).creationFlags).toBe(0) }) it("defaults the salt to random bits, and honours an explicit one", async () => { const { client, writeContract } = makeClient() await sendArkivTransaction(client, { creates: [validCreate] }) expect(sentCreate(writeContract).salt).toBeGreaterThan(0n) const { client: c2, writeContract: w2 } = makeClient() await sendArkivTransaction(c2, { creates: [{ ...validCreate, salt: 0n }] }) expect(sentCreate(w2).salt).toBe(0n) }) it("sends a zero salt for a create that opts out with NO_SALT", async () => { const { client, writeContract } = makeClient() await sendArkivTransaction(client, { creates: [{ ...validCreate, salt: NO_SALT }] }) expect(sentCreate(writeContract).salt).toBe(0n) }) it("rejects a salt wider than the uint128 field", async () => { const { client } = makeClient() await expect( sendArkivTransaction(client, { creates: [{ ...validCreate, salt: 2n ** 128n }] }), ).rejects.toThrow(/uint128/) }) }) describe("entity keys", () => { it("reports the key the engine minted, in batch order", async () => { const { client } = makeClient() const result = await sendArkivTransaction(client, { creates: [ { ...validCreate, salt: 1n }, { ...validCreate, salt: 1n }, { ...validCreate, salt: 1n }, ], }) // These come from the EntityCreated logs, so they are what the engine recorded rather than // what the SDK guessed — and the same salt three times cannot make them collide. expect(result.createdEntityKeys).toEqual([mintedKey(0), mintedKey(1), mintedKey(2)]) }) it("never reads the entity nonce, so two creates in flight cannot collide", async () => { // The nonce read was the race: two creates issued before either was mined both read the same // value, and one of them then returned a key the engine never minted. Reading the key back // from the receipt removes the window rather than narrowing it — and saves a round trip. const { client, readContract } = makeClient() await sendArkivTransaction(client, { creates: [validCreate] }) expect(readContract).not.toHaveBeenCalled() }) it("reports the expiry the engine resolved, not the one the SDK asked for", async () => { // The engine applies max(expiresAt, currentBlock + minLifetime) against the block the batch // actually lands on. Computing it here is a guess about which block that is. const { client, waitForTransactionReceipt } = makeClient() waitForTransactionReceipt.mockResolvedValue({ status: "success", transactionHash: "0xdeadbeef", logs: [ entityLog("EntityCreated", { entityKey: mintedKey(0), owner: OWNER, expiresAt: 999_999n, creationFlags: 0, }), ], }) const result = await sendArkivTransaction(client, { creates: [validCreate] }) expect(result.createdExpiries[0]).toBe(999_999n) }) it("ignores logs from another contract in the same transaction", async () => { const { client, waitForTransactionReceipt } = makeClient() const created = entityLog("EntityCreated", { entityKey: mintedKey(0), owner: OWNER, expiresAt: 500n, creationFlags: 0, }) waitForTransactionReceipt.mockResolvedValue({ status: "success", transactionHash: "0xdeadbeef", logs: [{ ...created, address: "0x9999999999999999999999999999999999999999" }, created], }) const result = await sendArkivTransaction(client, { creates: [validCreate] }) expect(result.createdEntityKeys).toEqual([mintedKey(0)]) }) it("refuses to guess when the events do not account for every create", async () => { const { client, waitForTransactionReceipt } = makeClient() waitForTransactionReceipt.mockResolvedValue({ status: "success", transactionHash: "0xdeadbeef", logs: [], }) const failure = await sendArkivTransaction(client, { creates: [validCreate, validCreate], }).catch((error: Error) => error) expect(failure).toBeInstanceOf(EntityMutationError) // This throws *after* a successful write, so the message has to stop a caller reading it as a // failure and retrying — which would create the entities a second time. expect(failure.message).toContain("do not retry") expect(failure.message).toContain("0xdeadbeef") }) }) describe("create through a schema", () => { /** The create's attributes, keyed by their bytes32 name. */ function sentCells(writeContract: ReturnType<typeof vi.fn>) { return Object.fromEntries(sentCreate(writeContract).attributes.map((a) => [a.name, a])) } it("rejects a value that does not fit the type it defaults to", async () => { const { client } = makeClient() const schema = defineSchema({ n: i32() }) await expect( sendArkivTransaction(client, { creates: [{ schema, values: { n: 1.5 }, expiration: DAY }] }), ).rejects.toThrow(InvalidValueError) }) it("rejects a column name outside the grammar before anything is written", () => { expect(() => defineSchema({ "1bad": i32() })).toThrow(InvalidAttributeNameError) }) it("rejects a null value, rather than writing a typeless one", async () => { const { client, writeContract } = makeClient() await expect( sendArkivTransaction(client, { creates: [{ ...validCreate, values: { category: "docs", title: null as never } }], }), ).rejects.toThrow(MissingValueError) expect(writeContract).not.toHaveBeenCalled() }) it("rejects a create that leaves out a required column", async () => { const { client, writeContract } = makeClient() await expect( sendArkivTransaction(client, { creates: [{ ...validCreate, values: {} }] }), ).rejects.toThrow(SchemaMismatchError) // Explicitly undefined is the same as left out. await expect( sendArkivTransaction(client, { creates: [{ ...validCreate, values: { category: undefined } as never }], }), ).rejects.toThrow(/required str column/) expect(writeContract).not.toHaveBeenCalled() }) it("checks the schema before any network call, so a transport failure cannot mask it", async () => { const { client } = makeClient() const getBlockNumber = (client as unknown as { getBlockNumber: ReturnType<typeof vi.fn> }) .getBlockNumber getBlockNumber.mockRejectedValue(new Error("fetch failed")) await expect( sendArkivTransaction(client, { creates: [{ ...validCreate, values: {} }] }), ).rejects.toThrow(SchemaMismatchError) expect(getBlockNumber).not.toHaveBeenCalled() }) it("rejects a column the schema does not have", async () => { const { client } = makeClient() const failure = await sendArkivTransaction(client, { creates: [{ ...validCreate, values: { category: "docs", nope: 1 } }], }).catch((error: Error) => error) expect(failure).toBeInstanceOf(SchemaMismatchError) expect((failure as SchemaMismatchError).columnName).toBe("nope") }) it("rejects a value whose type is not its column's", async () => { const { client } = makeClient() await expect( sendArkivTransaction(client, { creates: [{ ...validCreate, values: { category: i32(1) } }] }), ).rejects.toThrow(SchemaMismatchError) // A bare string is a str, never coerced into the column's type. const schema = defineSchema({ score: dec() }) await expect( sendArkivTransaction(client, { creates: [{ schema, values: { score: "4.1" }, expiration: DAY }], }), ).rejects.toThrow(/a dec column, but the value is a str/) }) it("lets an optional column be left out entirely", async () => { const { client, writeContract } = makeClient() await sendArkivTransaction(client, { creates: [validCreate] }) expect(sentCreate(writeContract).attributes.map((a) => a.name)).toEqual([name("category")]) }) it("writes a defaulted column's default when the create leaves it out", async () => { const schema = defineSchema({ category: str(), mimeType: str().default("text/markdown") }) const { client, writeContract } = makeClient() await sendArkivTransaction(client, { creates: [{ schema, values: { category: "docs" }, expiration: DAY }], }) expect(sentCells(writeContract)[name("mimeType")]).toMatchObject({ typeId: 8, value: toHex("text/markdown"), }) // ...and the value given, when it is given. const { client: c2, writeContract: w2 } = makeClient() await sendArkivTransaction(c2, { creates: [{ schema, values: { category: "docs", mimeType: "text/plain" }, expiration: DAY }], }) expect(sentCells(w2)[name("mimeType")].value).toBe(toHex("text/plain")) }) it("flags indexed columns in bit 7 of the typeId, and leaves the rest unindexed", async () => { const schema = defineSchema({ count: i32().indexed(), balance: u256().indexed(), score: dec().indexed(), author: addr(), title: str(), }) const { client, writeContract } = makeClient() await sendArkivTransaction(client, { creates: [ { schema, values: { count: 42, balance: u256(1n), score: dec("3.5"), author: addr("0x1111111111111111111111111111111111111111"), title: "Cells", }, expiration: DAY, }, ], }) const typeIds = Object.fromEntries( sentCreate(writeContract).attributes.map((a) => [a.name, a.typeId]), ) expect(typeIds[name("count")]).toBe(INDEXED | 2) expect(typeIds[name("balance")]).toBe(INDEXED | 4) expect(typeIds[name("score")]).toBe(INDEXED | 5) expect(typeIds[name("author")]).toBe(9) expect(typeIds[name("title")]).toBe(8) }) it("writes any number of bytes columns, unindexed, from bare Uint8Arrays or bytes()", async () => { const schema = defineSchema({ body: bytes(), thumbnail: bytes(), empty: bytes() }) const { client, writeContract } = makeClient() await sendArkivTransaction(client, { creates: [ { schema, values: { body: new Uint8Array([1, 2, 3]), thumbnail: bytes("0xDEAD"), empty: new Uint8Array(), }, expiration: DAY, }, ], }) const cells = sentCells(writeContract) expect(cells[name("body")]).toMatchObject({ typeId: 7, value: "0x010203" }) expect(cells[name("thumbnail")]).toMatchObject({ typeId: 7, value: "0xdead" }) // Empty is a value, not an absence: it is written rather than dropped. expect(cells[name("empty")]).toMatchObject({ typeId: 7, value: "0x" }) }) it("caps each bytes value at 128 KiB", async () => { const schema = defineSchema({ body: bytes(), thumbnail: bytes().optional() }) const { client, writeContract } = makeClient() await sendArkivTransaction(client, { creates: [{ schema, values: { body: new Uint8Array(MAX_BYTES_LENGTH) }, expiration: DAY }], }) expect(writeContract).toHaveBeenCalledTimes(1) const { client: c2 } = makeClient() await expect( sendArkivTransaction(c2, { creates: [ { schema, values: { body: new Uint8Array(MAX_BYTES_LENGTH + 1) }, expiration: DAY }, ], }), ).rejects.toThrow(InvalidValueError) }) it("caps the bytes of one create at 128 KiB in total", async () => { const schema = defineSchema({ body: bytes(), thumbnail: bytes() }) const half = new Uint8Array(MAX_ENTITY_BYTES / 2) const { client, writeContract } = makeClient() await sendArkivTransaction(client, { creates: [{ schema, values: { body: half, thumbnail: half }, expiration: DAY }], }) expect(writeContract).toHaveBeenCalledTimes(1) const { client: c2, writeContract: w2 } = makeClient() await expect( sendArkivTransaction(c2, { creates: [ { schema, values: { body: half, thumbnail: new Uint8Array(MAX_ENTITY_BYTES / 2 + 1) }, expiration: DAY, }, ], }), ).rejects.toThrow(EntityTooLargeError) expect(w2).not.toHaveBeenCalled() }) it("carries no cells for a schema with no columns", async () => { const { client, writeContract } = makeClient() await sendArkivTransaction(client, { creates: [{ schema: defineSchema({}), values: {}, expiration: DAY }], }) expect(sentCreate(writeContract).attributes).toEqual([]) }) it("sorts attributes ascending by their bytes32 name", async () => { const schema = defineSchema({ tag: str(), status: str().indexed(), score: i32() }) const { client, writeContract } = makeClient() await sendArkivTransaction(client, { creates: [{ schema, values: { tag: "zz", status: "active", score: 30 }, expiration: DAY }], }) expect(sentCreate(writeContract).attributes.map((a) => a.name)).toEqual([ name("score"), name("status"), name("tag"), ]) }) it("writes each create of a batch through its own schema", async () => { const Files = defineSchema({ path: str().indexed(), body: bytes() }) const { client, writeContract } = makeClient() const result = await sendArkivTransaction(client, { creates: [ validCreate, { schema: Files, values: { path: "/a", body: new Uint8Array([7]) }, expiration: DAY }, ], }) const [docs, files] = sentOperations(writeContract).map( (op) => decodeAbiParameters(CREATE_PARAMS, op.operationData)[0], ) expect(docs.attributes.map((a) => [a.name, a.typeId])).toEqual([ [name("category"), INDEXED | 8], ]) expect(files.attributes.map((a) => [a.name, a.typeId])).toEqual([ [name("body"), 7], [name("path"), INDEXED | 8], ]) expect(result.createdEntityKeys).toEqual([mintedKey(0), mintedKey(1)]) }) }) describe("patch", () => { /** The decoded Patch struct of the batch's only patch. */ function sentPatch(writeContract: ReturnType<typeof vi.fn>) { const op = sentOperations(writeContract).find((o) => o.operation === OperationType.Patch) if (!op) throw new Error("no Patch operation was sent") return decodeAbiParameters(PATCH_PARAMS, op.operationData)[0] } it("carries only what the patch named, flagged as its column declares", async () => { const { client, writeContract } = makeClient() await sendArkivTransaction(client, { patches: [{ schema: Docs, entityKey: ENTITY_KEY, set: { level: i32(11) } }], }) const patch = sentPatch(writeContract) expect(patch.entityKey).toBe(ENTITY_KEY) // No tombstones, nothing unnamed: everything else is left alone. expect(patch.mutations).toEqual([ { name: name("level"), typeId: INDEXED | 2, value: expect.any(String) }, ]) }) it("flips a column's indexing when the schema writing it says so", async () => { const Indexed = defineSchema({ title: str().indexed() }) const { client, writeContract } = makeClient() await sendArkivTransaction(client, { patches: [{ schema: Indexed, entityKey: ENTITY_KEY, set: { title: "x" } }], }) expect(sentPatch(writeContract).mutations[0].typeId).toBe(INDEXED | 8) const { client: c2, writeContract: w2 } = makeClient() await sendArkivTransaction(c2, { patches: [{ schema: Docs, entityKey: ENTITY_KEY, set: { title: "x" } }], }) expect(sentPatch(w2).mutations[0].typeId).toBe(8) }) it("does not apply defaults — a patch writes only what it names", async () => { const schema = defineSchema({ category: str(), mimeType: str().default("text/markdown") }) const { client, writeContract } = makeClient() await sendArkivTransaction(client, { patches: [{ schema, entityKey: ENTITY_KEY, set: { category: "x" } }], }) expect(sentPatch(writeContract).mutations.map((m) => m.name)).toEqual([name("category")]) }) it("tombstones an unset optional column with typeId 0 and an empty value", async () => { const { client, writeContract } = makeClient() await sendArkivTransaction(client, { patches: [{ schema: Docs, entityKey: ENTITY_KEY, unset: ["title"] }], }) expect(sentPatch(writeContract).mutations).toEqual([ { name: name("title"), typeId: 0, value: "0x" }, ]) }) it("refuses to unset a required column", async () => { const { client, writeContract } = makeClient() await expect( sendArkivTransaction(client, { patches: [{ schema: Docs, entityKey: ENTITY_KEY, unset: ["category" as never] }], }), ).rejects.toThrow(SchemaMismatchError) expect(writeContract).not.toHaveBeenCalled() }) it("rejects a set of a column the schema does not have, or of the wrong type", async () => { const { client } = makeClient() await expect( sendArkivTransaction(client, { patches: [{ schema: Docs, entityKey: ENTITY_KEY, set: { nope: 1 } as never }], }), ).rejects.toThrow(SchemaMismatchError) await expect( sendArkivTransaction(client, { patches: [{ schema: Docs, entityKey: ENTITY_KEY, set: { level: "high" } as never }], }), ).rejects.toThrow(/i32 column, but the value is a str/) }) it("leaves out a column set to undefined, as a create does", async () => { const { client, writeContract } = makeClient() await sendArkivTransaction(client, { patches: [{ schema: Docs, entityKey: ENTITY_KEY, set: { level: undefined, title: "x" } }], }) expect(sentPatch(writeContract).mutations.map((m) => m.name)).toEqual([name("title")]) }) it("treats a patch of nothing but undefined values as empty", async () => { const { client } = makeClient() await expect( sendArkivTransaction(client, { patches: [{ schema: Docs, entityKey: ENTITY_KEY, set: { level: undefined } }], }), ).rejects.toThrow(EmptyPatchError) }) it("does not count an undefined set as conflicting with an unset", async () => { const { client, writeContract } = makeClient() await sendArkivTransaction(client, { patches: [ { schema: Docs, entityKey: ENTITY_KEY, set: { title: undefined }, unset: ["title"] }, ], }) expect(sentPatch(writeContract).mutations).toEqual([ { name: name("title"), typeId: 0, value: "0x" }, ]) }) it("still rejects null, which is not 'left out'", async () => { const { client } = makeClient() await expect( sendArkivTransaction(client, { patches: [{ schema: Docs, entityKey: ENTITY_KEY, set: { level: null } as never }], }), ).rejects.toThrow(MissingValueError) }) it("sorts writes and tombstones together, not each half on its own", async () => { const schema = defineSchema({ zeta: str(), beta: str(), alpha: str().optional(), gamma: str().optional(), }) const { client, writeContract } = makeClient() await sendArkivTransaction(client, { patches: [ { schema, entityKey: ENTITY_KEY, set: { zeta: "z", beta: "b" }, unset: ["alpha", "gamma"], }, ], }) expect(sentPatch(writeContract).mutations.map((m) => m.name)).toEqual([ name("alpha"), name("beta"), name("gamma"), name("zeta"), ]) }) it("collapses a name repeated in unset, since it is the same intent twice", async () => { const { client, writeContract } = makeClient() await sendArkivTransaction(client, { patches: [{ schema: Docs, entityKey: ENTITY_KEY, unset: ["title", "title"] }], }) // Two identical tombstones would break the engine's strict-ascending uniqueness rule. expect(sentPatch(writeContract).mutations).toHaveLength(1) }) it("rejects a name that is both set and unset", async () => { const { client, writeContract } = makeClient() await expect( sendArkivTransaction(client, { patches: [ { schema: Docs, entityKey: ENTITY_KEY, set: { level: i32(1) }, unset: ["level"] }, ], }), ).rejects.toThrow(ConflictingMutationError) expect(writeContract).not.toHaveBeenCalled() }) it("rejects a patch with nothing to apply", async () => { const { client, writeContract } = makeClient() await expect( sendArkivTransaction(client, { patches: [{ schema: Docs, entityKey: ENTITY_KEY }] }), ).rejects.toThrow(EmptyPatchError) // Present but empty is just as empty. await expect( sendArkivTransaction(client, { patches: [{ schema: Docs, entityKey: ENTITY_KEY, set: {}, unset: [] }], }), ).rejects.toThrow(EmptyPatchError) expect(writeContract).not.toHaveBeenCalled() }) it("rejects a bare string as unset, rather than tombstoning its letters", async () => { const { client, writeContract } = makeClient() // A Set is built from any iterable, so "title" would become tombstones for t, i, t, l and e — // and the real "title" left untouched. await expect( sendArkivTransaction(client, { patches: [{ schema: Docs, entityKey: ENTITY_KEY, unset: "title" as never }], }), ).rejects.toThrow(/must be an array of column names/) expect(writeContract).not.toHaveBeenCalled() }) it("rejects unsetting a system attribute, which is no column of any schema", async () => { const { client } = makeClient() await expect( sendArkivTransaction(client, { patches: [{ schema: Docs, entityKey: ENTITY_KEY, unset: ["$owner" as never] }], }), ).rejects.toThrow(SchemaMismatchError) }) it("does not fetch a block height for a patch-only batch", async () => { const { client } = makeClient() await sendArkivTransaction(client, { patches: [{ schema: Docs, entityKey: ENTITY_KEY, set: { level: i32(1) } }], }) // A patch carries no expiry, so there is nothing to resolve against the chain. expect(client.getBlockNumber).not.toHaveBeenCalled() }) }) describe("failures that are not reverts", () => { it("keeps an unrecognised error's message and the error itself as the cause", async () => { const { client } = makeClient() // The transaction was broadcast; it is waiting for the receipt that times out. What that error // knows — not least the hash it was watching — is the only way to find out whether the patch // landed, so summarising it away as "Transaction failed" would strand the caller. const timeout = new Error("Timed out while waiting for transaction 0xabc to be confirmed.") ;(client.waitForTransactionReceipt as ReturnType<typeof vi.fn>).mockRejectedValue(timeout) const failure = await sendArkivTransaction(client, { patches: [{ schema: Docs, entityKey: ENTITY_KEY, set: { level: i32(1) } }], }).catch((error: Error) => error) expect(failure).toBeInstanceOf(EntityMutationError) expect(failure.message).toContain("0xabc") expect(failure.cause).toBe(timeout) }) }) describe("a revert the node catches before there is a receipt", () => { /** A decoded engine revert, wrapped the way viem delivers one out of `writeContract`. */ function preflightRevert(errorName: string, args: readonly unknown[]) { const inner = new ContractFunctionRevertedError({ abi: [], functionName: "execute" }) Object.assign(inner, { data: { errorName, args } }) return new BaseError('The contract function "execute" reverted.', { cause: inner }) } it("explains it, rather than leaving the explainer to the rarer post-receipt path", async () => { // Most reverts never reach the receipt: the node rejects them during gas estimation, so // `writeContract` throws and there is nothing to replay. Wiring the named-error decoder only // into the replay path would explain the rare case and stay silent on the common one. const { client } = makeClient() ;(client.writeContract as ReturnType<typeof vi.fn>).mockRejectedValue( preflightRevert("ReadOnlyEntity", [ENTITY_KEY]), ) const failure = await sendArkivTransaction(client, { patches: [{ schema: Docs, entityKey: ENTITY_KEY, set: { level: i32(1) } }], }).catch((error: Error) => error) expect(failure).toBeInstanceOf(EntityMutationError) expect(failure.message).toContain("readonly") // viem's own rendering prints the decoded args positionally — the right data, none of the // meaning — so the point is that it is *not* what surfaces. expect(failure.message).not.toContain('The contract function "execute" reverted') }) it("still names a bad attribute-name byte, which only ever fails at estimation", async () => { const { client } = makeClient() ;(client.writeContract as ReturnType<typeof vi.fn>).mockRejectedValue( preflightRevert("Ident32InvalidByte", [2n, "0x2f"]), ) const failure = await sendArkivTransaction(client, { patches: [{ schema: Docs, entityKey: ENTITY_KEY, set: { level: i32(1) } }], }).catch((error: Error) => error) expect(failure.message).toContain('"/"') expect(failure.message).toContain("byte 2") }) it("leaves an error it cannot decode to the existing handling", async () => { const { client } = makeClient() const opaque = new BaseError("nonce too low") ;(client.writeContract as ReturnType<typeof vi.fn>).mockRejectedValue(opaque) const failure = await sendArkivTransaction(client, { patches: [{ schema: Docs, entityKey: ENTITY_KEY, set: { level: i32(1) } }], }).catch((error: Error) => error) expect(failure.message).toContain("nonce too low") expect(failure.cause).toBe(opaque) }) }) describe("gas headroom", () => { /** The gas limit a batch whose estimate is `estimate` is sent with. */ async function sentGasFor(estimate: bigint) { const { client, estimateGas, writeContract } = makeClient() estimateGas.mockResolvedValue(estimate) await sendArkivTransaction(client, { deletes: [{ entityKey: ENTITY_KEY }] }) return writeContract.mock.calls[0][0].gas as bigint } it("adds 25% to the estimate", async () => { expect(await sentGasFor(100_000n)).toBe(125_000n) }) it("stops at the per-transaction cap when the headroom would cross it", async () => { expect(await sentGasFor(MAX_TRANSACTION_GAS - 1_000n)).toBe(MAX_TRANSACTION_GAS) }) it("sends an estimate already above the cap as it is", async () => { expect(await sentGasFor(MAX_TRANSACTION_GAS + 1_000n)).toBe(MAX_TRANSACTION_GAS + 1_000n) }) })