@arkiv-network/sdk
Version:
TypeScript client library for Arkiv's blockchains interactions
208 lines (184 loc) • 7.82 kB
text/typescript
import { describe, expect, it } from "bun:test"
import { decodeAbiParameters, parseAbiParameters, toHex } from "viem"
import { encodeCreate } from "../schema/attributes"
import { defineSchema } from "../schema/schema"
import { bytes, dec, i32, str, u256 } from "../schema/values"
import { InvalidCreationFlagsError } from "./errors"
import { decodeCreationFlags, encodeCreationFlags } from "./flags"
import { MAX_SALT, NO_SALT, predictEntityKey, randomSalt, resolveSalt, validateSalt } from "./key"
import { createOperation } from "./operations"
import { OperationType } from "./params"
const OWNER = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
const CREATE_PARAMS = parseAbiParameters(
"(uint128 salt, uint64 expiresAt, uint64 minLifetime, uint8 creationFlags, (bytes32 name, uint8 typeId, bytes value)[] attributes)",
)
/** Decodes an operation's payload back into the Create struct it encodes. */
function decodeCreate(operationData: `0x${string}`) {
const [create] = decodeAbiParameters(CREATE_PARAMS, operationData)
return create
}
describe("creation flags", () => {
it("packs each flag into its documented bit", () => {
expect(encodeCreationFlags({})).toBe(0b00)
expect(encodeCreationFlags({ readonly: true })).toBe(0b01)
expect(encodeCreationFlags({ permissionlessExtension: true })).toBe(0b10)
expect(encodeCreationFlags({ readonly: true, permissionlessExtension: true })).toBe(0b11)
expect(encodeCreationFlags()).toBe(0)
})
it("round-trips through the byte", () => {
for (const readonly of [true, false]) {
for (const permissionlessExtension of [true, false]) {
const decoded = decodeCreationFlags(
encodeCreationFlags({ readonly, permissionlessExtension }),
)
expect(decoded.readonly).toBe(readonly)
expect(decoded.permissionlessExtension).toBe(permissionlessExtension)
}
}
})
it("refuses a value that is not a byte at all", () => {
// Malformed input
expect(() => decodeCreationFlags(256)).toThrow(InvalidCreationFlagsError)
expect(() => decodeCreationFlags(256)).toThrow(/not a byte/)
expect(() => decodeCreationFlags(-1)).toThrow(/not a byte/)
})
it("keeps the raw byte, so unknown state stays visible", () => {
expect(decodeCreationFlags(0b11).raw).toBe(3)
})
})
describe("salt", () => {
it("generates 128 bits within the wire field", () => {
for (let i = 0; i < 32; i++) {
const salt = randomSalt()
expect(salt).toBeGreaterThanOrEqual(0n)
expect(salt).toBeLessThanOrEqual(MAX_SALT)
}
})
it("does not repeat", () => {
const seen = new Set(Array.from({ length: 64 }, () => randomSalt().toString()))
expect(seen.size).toBe(64)
})
it("bounds a caller-supplied salt to the uint128 field", () => {
expect(validateSalt(0n)).toBe(0n)
expect(validateSalt(MAX_SALT)).toBe(MAX_SALT)
expect(() => validateSalt(MAX_SALT + 1n)).toThrow(/uint128/)
expect(() => validateSalt(-1n)).toThrow(/uint128/)
})
it("resolves NO_SALT to the zero, and passes anything else through", () => {
expect(resolveSalt(NO_SALT)).toBe(0n)
expect(resolveSalt(0n)).toBe(0n)
expect(resolveSalt(42n)).toBe(42n)
expect(() => resolveSalt(MAX_SALT + 1n)).toThrow(/uint128/)
})
it("reports a salt it cannot use without choking on a symbol", () => {
// @ts-expect-error
expect(() => validateSalt(Symbol("nope"))).toThrow(/Invalid salt/)
})
})
describe("predictEntityKey", () => {
it("is deterministic in owner, nonce and salt", () => {
const base = { owner: OWNER, nonce: 0n, salt: 7n, chainId: 1 } as const
expect(predictEntityKey(base)).toBe(predictEntityKey(base))
expect(predictEntityKey({ ...base, nonce: 1n })).not.toBe(predictEntityKey(base))
expect(predictEntityKey({ ...base, salt: 8n })).not.toBe(predictEntityKey(base))
expect(
predictEntityKey({ ...base, owner: "0x1111111111111111111111111111111111111111" }),
).not.toBe(predictEntityKey(base))
})
it("derives the same key from NO_SALT as from an explicit zero", () => {
const base = { owner: OWNER, nonce: 3n, chainId: 1 } as const
expect(predictEntityKey({ ...base, salt: NO_SALT })).toBe(
predictEntityKey({ ...base, salt: 0n }),
)
})
it("separates chains through the domain", () => {
const base = { owner: OWNER, nonce: 0n, salt: 0n } as const
expect(predictEntityKey({ ...base, chainId: 1 })).not.toBe(
predictEntityKey({ ...base, chainId: 2 }),
)
})
it("returns a 32-byte key", () => {
expect(predictEntityKey({ owner: OWNER, nonce: 0n, salt: 0n, chainId: 1 })).toMatch(
/^0x[0-9a-f]{64}$/,
)
})
})
describe("the Create operation", () => {
const build = (over: Partial<Parameters<typeof createOperation>[0]> = {}) =>
createOperation({
salt: 42n,
expiry: { expiresAt: 0n, minLifetime: 43_200n },
creationFlags: 0,
attributes: [],
...over,
})
it("carries the CREATE tag", () => {
expect(build().operation).toBe(OperationType.Create)
})
it("round-trips its fields through the ABI", () => {
const create = decodeCreate(
build({
salt: 42n,
expiry: { expiresAt: 1_200_000n, minLifetime: 100n },
creationFlags: 0b11,
}).operationData,
)
expect(create.salt).toBe(42n)
expect(create.expiresAt).toBe(1_200_000n)
expect(create.minLifetime).toBe(100n)
expect(create.creationFlags).toBe(3)
})
it("carries attributes as (name, typeId, bytes), sorted ascending by name", () => {
const schema = defineSchema({ zebra: i32(), apple: str(), mango: u256() })
const create = decodeCreate(
build({
attributes: encodeCreate(schema, { zebra: i32(1), apple: str("x"), mango: u256(2n) }),
}).operationData,
)
const names = create.attributes.map((a) => a.name)
expect(names).toEqual([...names].sort())
expect(create.attributes.map((a) => a.typeId)).toEqual([8, 4, 2]) // apple str, mango u256, zebra i32
})
it("sets bit 7 of the typeId on indexed columns, and only on them", () => {
const schema = defineSchema({ level: i32().indexed(), note: str(), body: bytes() })
const create = decodeCreate(
build({
attributes: encodeCreate(schema, {
level: 10,
note: "hi",
body: new Uint8Array([0xde, 0xad]),
}),
}).operationData,
)
const byName = Object.fromEntries(create.attributes.map((a) => [a.name, a]))
expect(byName[toHex("level", { size: 32 })].typeId).toBe(0x80 | 2)
expect(byName[toHex("note", { size: 32 })].typeId).toBe(8)
expect(byName[toHex("body", { size: 32 })]).toMatchObject({ typeId: 7, value: "0xdead" })
})
it("carries no cells at all for an entity with no columns", () => {
const create = decodeCreate(
build({ attributes: encodeCreate(defineSchema({}), {}) }).operationData,
)
expect(create.attributes).toHaveLength(0)
})
it("encodes each value type at its documented width", () => {
const schema = defineSchema({ n: i32(), big: u256(), d: dec(), s: str() })
const create = decodeCreate(
build({
attributes: encodeCreate(schema, {
n: i32(-42),
big: u256(1n),
d: dec("1.5"),
s: str("hi"),
}),
}).operationData,
)
const byName = Object.fromEntries(create.attributes.map((a) => [a.name, a.value]))
// Word types are exactly 32 bytes; i32 is sign-extended.
expect(byName[toHex("n", { size: 32 })]).toBe(`0x${"ff".repeat(31)}d6`)
expect(byName[toHex("big", { size: 32 })]).toBe(`0x${"00".repeat(31)}01`)
expect(BigInt(byName[toHex("d", { size: 32 })])).toBe(1_500_000_000_000_000_000n)
// str is raw UTF-8, not padded.
expect(byName[toHex("s", { size: 32 })]).toBe("0x6869")
})
})