@arkiv-network/sdk
Version:
TypeScript client library for Arkiv's blockchains interactions
201 lines (177 loc) • 8.1 kB
text/typescript
import { describe, expect, it } from "bun:test"
import type { Hex } from "viem"
import {
asTypeTag,
decodeRpcValue,
decodeTypeId,
decodeValueBytes,
encodeAbiAttribute,
encodeTombstone,
encodeValueBytes,
} from "./codec"
import { InvalidValueError, UnknownAttributeTypeError } from "./errors"
import type { ArkivValue } from "./types"
import { addr, bool, bytes, dec, i32, key, str, u64, u256 } from "./values"
const ZERO = `0x${"00".repeat(32)}` as Hex
const KEY = `0x${"ab".repeat(32)}` as Hex
const VITALIK = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
/** One value of every type, including the awkward edges of each. */
const SAMPLES: ArkivValue[] = [
bool(true),
bool(false),
i32(0),
i32(-42),
i32(2147483647),
i32(-2147483648),
u64(0n),
u64(2n ** 64n - 1n),
u256(0n),
u256(2n ** 256n - 1n),
dec("0"),
dec("3.5"),
dec("-3.5"),
dec("-0.000000000000000001"),
key(KEY),
addr(VITALIK),
str(""),
str("hello"),
str("é".repeat(64)),
bytes(new Uint8Array()),
bytes("0xdeadbeef"),
]
describe("ABI value encoding", () => {
it("round-trips every type", () => {
for (const value of SAMPLES) {
const back = decodeValueBytes(value.type, encodeValueBytes(value))
expect(back.type).toBe(value.type)
expect(back.value).toEqual(value.value)
}
})
it("gives every word type exactly 32 bytes", () => {
for (const value of SAMPLES) {
if (value.type === "str" || value.type === "bytes") continue
expect(encodeValueBytes(value)).toHaveLength(2 + 64)
}
})
it("sign-extends a negative i32 across the whole word, as Solidity does", () => {
expect(encodeValueBytes(i32(-1))).toBe(`0x${"ff".repeat(32)}`)
expect(encodeValueBytes(i32(-42))).toBe(`0x${"ff".repeat(31)}d6`)
expect(encodeValueBytes(i32(42))).toBe(`0x${"00".repeat(31)}2a`)
})
it("stores a dec as its two's-complement scaled int256", () => {
const scaled = (value: string) => BigInt.asIntN(256, BigInt(encodeValueBytes(dec(value))))
expect(scaled("1.5")).toBe(1_500_000_000_000_000_000n)
expect(scaled("-1.5")).toBe(-1_500_000_000_000_000_000n)
expect(scaled("0")).toBe(0n)
})
it("right-aligns an address and a bool", () => {
expect(encodeValueBytes(addr(VITALIK))).toBe(
`0x${"00".repeat(12)}${VITALIK.slice(2).toLowerCase()}`,
)
expect(encodeValueBytes(bool(true))).toBe(`0x${"00".repeat(31)}01`)
expect(encodeValueBytes(bool(false))).toBe(ZERO)
})
it("gives a str its raw UTF-8 bytes, with no padding", () => {
expect(encodeValueBytes(str("hi"))).toBe("0x6869")
expect(encodeValueBytes(str(""))).toBe("0x")
// Two bytes per "é", so no relationship to a 32-byte word.
expect(encodeValueBytes(str("é"))).toBe("0xc3a9")
})
it("gives bytes their raw bytes, with no padding", () => {
expect(encodeValueBytes(bytes("0xdeadbeef"))).toBe("0xdeadbeef")
expect(encodeValueBytes(bytes(new Uint8Array()))).toBe("0x")
})
it("rejects a word a well-formed encoder would not have produced", () => {
expect(() => decodeValueBytes("bool", `0x${"00".repeat(31)}02`)).toThrow(/exactly 0 or 1/)
expect(() => decodeValueBytes("u256", "0x2a")).toThrow(/must be 32 bytes, got 1/)
// An i32 whose upper bytes contradict its sign bit.
expect(() => decodeValueBytes("i32", `0xff${"00".repeat(31)}`)).toThrow(/sign-extended/)
// An address with junk above its 20 bytes.
expect(() => decodeValueBytes("addr", `0x${"ab".repeat(32)}`)).toThrow(/zero-padded/)
// A u64 with junk above its 8 bytes — truncating it would invent a plausible block height.
expect(() => decodeValueBytes("u64", `0x${"ab".repeat(32)}`)).toThrow(/zero-padded/)
})
})
describe("encodeAbiAttribute", () => {
it("packs the name left-aligned into its bytes32 cell", () => {
const attribute = encodeAbiAttribute("level", i32(10), false)
// "level" is 6c6576656c, null-padded to 32 bytes.
expect(attribute.name).toBe(`0x6c6576656c${"00".repeat(27)}`)
expect(attribute.typeId).toBe(2)
})
it("sets bit 7 of the typeId for an indexed attribute, and only that bit", () => {
expect(encodeAbiAttribute("level", i32(10), true).typeId).toBe(0x82)
expect(encodeAbiAttribute("tag", str("x"), true).typeId).toBe(0x88)
expect(encodeAbiAttribute("tag", str("x"), false).typeId).toBe(0x08)
})
it("refuses to index bytes, which the engine would revert", () => {
expect(() => encodeAbiAttribute("body", bytes("0x01"), true)).toThrow(/never indexed/)
expect(encodeAbiAttribute("body", bytes("0x01"), false).typeId).toBe(7)
})
it("encodes a tombstone as a zero-length value under typeId 0", () => {
expect(encodeTombstone("flagged")).toEqual({
name: `0x666c6167676564${"00".repeat(25)}`,
typeId: 0,
value: "0x",
})
})
})
describe("decodeTypeId", () => {
it("splits the type from the indexed flag", () => {
expect(decodeTypeId(0x88)).toEqual({ tag: "str", indexed: true })
expect(decodeTypeId(0x07)).toEqual({ tag: "bytes", indexed: false })
expect(decodeTypeId(0x87)).toEqual({ tag: "bytes", indexed: true })
})
it("rejects a type it does not know, flagged or not", () => {
expect(() => decodeTypeId(0x7f)).toThrow(UnknownAttributeTypeError)
expect(() => decodeTypeId(0x80)).toThrow(UnknownAttributeTypeError)
})
it("reads typeId 6, the node's bytes32 type, as a key", () => {
expect(decodeTypeId(0x06)).toEqual({ tag: "key", indexed: false })
expect(decodeTypeId(0x86)).toEqual({ tag: "key", indexed: true })
expect(decodeRpcValue("bytes32", KEY)).toEqual(key(KEY))
expect(asTypeTag("bytes32")).toBe("key")
})
})
describe("decodeRpcValue", () => {
it("reads the JSON encoding each type declares", () => {
expect(decodeRpcValue("bool", true).value).toBe(true)
expect(decodeRpcValue("i32", -42).value).toBe(-42)
expect(decodeRpcValue("u256", "0xf4240").value).toBe(1_000_000n)
expect(decodeRpcValue("dec", "3.5").value).toBe("3.5")
expect(decodeRpcValue("dec", "-0.25").value).toBe("-0.25")
expect(decodeRpcValue("str", "hello").value).toBe("hello")
expect(decodeRpcValue("addr", VITALIK.toLowerCase()).value).toBe(VITALIK)
expect(decodeRpcValue("key", KEY).value).toBe(KEY)
})
it("takes an integer however it is rendered", () => {
// QUANTITY is the spec's encoding; decimal strings and numbers are tolerated.
expect(decodeRpcValue("u256", "0x2a").value).toBe(42n)
expect(decodeRpcValue("u256", "42").value).toBe(42n)
expect(decodeRpcValue("u256", 42).value).toBe(42n)
expect(decodeRpcValue("i32", "-42").value).toBe(-42)
})
it("decodes bytes as lowercase hex", () => {
expect(decodeRpcValue("bytes", "0xDEADBEEF").value).toBe("0xdeadbeef")
})
it("names the type it does not recognise, and says to upgrade", () => {
expect(() => decodeRpcValue("int", "x")).toThrow(UnknownAttributeTypeError)
expect(() => decodeRpcValue("int", "x")).toThrow(/upgrade @arkiv-network\/sdk/)
// The pre-spec wire form: a typeId where the JSON-RPC surface names a tag.
expect(() => decodeRpcValue(3 as unknown as string, "0x2a")).toThrow(UnknownAttributeTypeError)
})
it("does not mistake an inherited Object key for a type tag", () => {
// `type in TYPE_IDS` walks the prototype chain, so these passed the check and then fell off
// the end of the exhaustive switch, returning `undefined` — the caller got a TypeError from
// `entity.attributes.foo.value` instead of "upgrade the SDK".
for (const inherited of ["constructor", "toString", "valueOf", "hasOwnProperty"]) {
expect(() => decodeRpcValue(inherited, 1)).toThrow(UnknownAttributeTypeError)
}
})
it("rejects a value whose JSON shape contradicts its declared type", () => {
expect(() => decodeRpcValue("u256", "not a number")).toThrow(InvalidValueError)
expect(() => decodeRpcValue("str", 42)).toThrow(/expected a string/)
expect(() => decodeRpcValue("bool", "yes")).toThrow(/not a boolean/)
expect(() => decodeRpcValue("i32", 1.5)).toThrow(/not an integer/)
})
})