UNPKG

@arkiv-network/sdk

Version:

TypeScript client library for Arkiv's blockchains interactions

340 lines (298 loc) • 12.8 kB
import { describe, expect, it } from "bun:test" import { isColumn } from "./column" import { InvalidValueError, MissingValueError, UntypedValueError } from "./errors" import { TYPE_IDS } from "./types" import { addr, bool, bytes, dec, decFromUnits, decUnits, I32_MAX, I32_MIN, i32, key, MAX_BYTES_LENGTH, str, toValue, U64_MAX, U256_MAX, u64, u256, } from "./values" const KEY = `0x${"ab".repeat(32)}` as const const VITALIK = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" describe("tagged constructors", () => { it("tag each value with the type it was built as", () => { const built = [ [bool(true), "bool", true], [i32(10), "i32", 10], [u256(10n), "u256", 10n], [dec("3.5"), "dec", "3.5"], [str("Bob"), "str", "Bob"], [key(KEY), "key", KEY], [addr(VITALIK), "addr", VITALIK], ] as const for (const [value, tag, decoded] of built) { expect(value.type).toBe(tag) expect(value.value).toBe(decoded) } }) it("keep the tag distinct from the wire typeId", () => { // The tag is what a client says; the typeId is what the wire carries. expect(TYPE_IDS[i32(1).type]).toBe(2) expect(TYPE_IDS[key(KEY).type]).toBe(10) }) }) describe("i32", () => { it("accepts the full signed 32-bit range", () => { expect(i32(I32_MIN).value).toBe(I32_MIN) expect(i32(I32_MAX).value).toBe(I32_MAX) expect(i32(-42).value).toBe(-42) expect(i32(0n).value).toBe(0) }) it("rejects values outside the range, naming the type that fits", () => { expect(() => i32(I32_MAX + 1)).toThrow(InvalidValueError) expect(() => i32(Date.now())).toThrow(/use u64/) expect(() => i32(I32_MIN - 1)).toThrow(/use dec/) expect(() => i32(2n ** 40n)).toThrow(InvalidValueError) }) it("rejects non-integers, quoting the dec call that would work", () => { expect(() => i32(3.5)).toThrow(/dec\("3\.5"\)/) expect(() => i32(Number.NaN)).toThrow(InvalidValueError) expect(() => i32(Number.POSITIVE_INFINITY)).toThrow(InvalidValueError) }) }) describe("u64", () => { it("accepts bigints, safe numbers, and decimal or hex strings", () => { expect(u64(0n).value).toBe(0n) expect(u64(U64_MAX).value).toBe(U64_MAX) expect(u64(1_200_000).value).toBe(1_200_000n) expect(u64("1200000").value).toBe(1_200_000n) expect(u64("0x124f80").value).toBe(1_200_000n) }) it("rejects negatives, overflow, and lossy numbers", () => { expect(() => u64(-1n)).toThrow(/unsigned/) expect(() => u64(U64_MAX + 1n)).toThrow(/wider than 64 bits/) expect(() => u64(2 ** 53)).toThrow(/safe integer/) expect(() => u64(1.5)).toThrow(/dec\("1\.5"\)/) expect(() => u64("12abc")).toThrow(InvalidValueError) }) it("points at u256 when the value is simply too wide", () => { expect(() => u64(U64_MAX + 1n)).toThrow(/Use u256/) }) it("is a distinct type from u256, not an alias for a small one", () => { expect(TYPE_IDS[u64(1n).type]).toBe(3) expect(TYPE_IDS[u256(1n).type]).toBe(4) }) }) describe("u256", () => { it("accepts bigints, safe numbers, and decimal or hex strings", () => { expect(u256(0n).value).toBe(0n) expect(u256(U256_MAX).value).toBe(U256_MAX) expect(u256(1_000_000).value).toBe(1_000_000n) expect(u256("1000000").value).toBe(1_000_000n) expect(u256("0xf4240").value).toBe(1_000_000n) }) it("rejects negatives, overflow, and lossy numbers", () => { expect(() => u256(-1n)).toThrow(/unsigned/) expect(() => u256(U256_MAX + 1n)).toThrow(/wider than 256 bits/) expect(() => u256(2 ** 53)).toThrow(/safe integer/) expect(() => u256(1.5)).toThrow(/dec\("1\.5"\)/) expect(() => u256("12abc")).toThrow(InvalidValueError) }) }) describe("dec", () => { it("canonicalises the decimal it was given", () => { expect(dec("3.5").value).toBe("3.5") expect(dec("3.50").value).toBe("3.5") expect(dec("003.5").value).toBe("3.5") expect(dec("+3.5").value).toBe("3.5") expect(dec("-0.0").value).toBe("0") expect(dec("5").value).toBe("5") expect(dec(5).value).toBe("5") expect(dec(-5n).value).toBe("-5") }) it("holds exactly 18 decimal places", () => { expect(dec("0.000000000000000001").value).toBe("0.000000000000000001") expect(decUnits(dec("0.000000000000000001"))).toBe(1n) expect(decUnits(dec("3.5"))).toBe(3_500_000_000_000_000_000n) expect(decUnits(dec("-3.5"))).toBe(-3_500_000_000_000_000_000n) }) it("errors rather than rounding away excess precision", () => { expect(() => dec("0.0000000000000000001")).toThrow(/19 fractional digits/) expect(() => dec("1e18")).toThrow(/not a decimal literal/) expect(() => dec("3.")).toThrow(/not a decimal literal/) expect(() => dec(".5")).toThrow(/not a decimal literal/) }) it("refuses fractional numbers, since binary floats cannot hold them", () => { expect(() => dec(3.5)).toThrow(/dec\("3\.5"\)/) expect(() => dec(0.1 + 0.2)).toThrow(/dec\("0\.30000000000000004"\)/) }) it("round-trips through its scaled units", () => { for (const literal of ["0", "1", "-1", "3.5", "-0.25", "123456789.000000000000000001"]) { expect(decFromUnits(decUnits(dec(literal))).value).toBe(dec(literal).value) } }) }) describe("str", () => { it("measures its 128-byte limit in bytes, not characters", () => { expect(str("a".repeat(128)).value).toHaveLength(128) expect(() => str("a".repeat(129))).toThrow(/129 UTF-8 bytes/) // "é" is two UTF-8 bytes, so 64 of them exactly fill the limit. expect(str("é".repeat(64)).value).toHaveLength(64) expect(() => str("é".repeat(65))).toThrow(/130 UTF-8 bytes/) }) it("allows any character, control characters included", () => { for (const value of ["a\nb", "a\tb", "\u0000", "\u007f", "😀"]) { expect(str(value).value).toBe(value) } }) it("rejects a lone surrogate, which has no UTF-8 encoding", () => { expect(() => str("a\ud800b")).toThrow(/lone surrogate at index 1/) expect(() => str("\udc00")).toThrow(/lone surrogate at index 0/) expect(() => str("a\ud83d")).toThrow(/lone surrogate at index 1/) }) }) describe("addr", () => { it("normalises to EIP-55 regardless of the case it was given", () => { expect(addr(VITALIK).value).toBe(VITALIK) expect(addr(VITALIK.toLowerCase()).value).toBe(VITALIK) expect(addr(`0x${VITALIK.slice(2).toUpperCase()}`).value).toBe(VITALIK) }) it("catches a mistyped mixed-case address via its checksum", () => { const mistyped = `0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96046` expect(() => addr(mistyped)).toThrow(/EIP-55 checksum/) expect(() => addr(mistyped)).toThrow(/correctly checksummed form is 0x/) }) it("rejects anything that is not 20 hex bytes", () => { expect(() => addr("0x1234")).toThrow(InvalidValueError) expect(() => addr(KEY)).toThrow(InvalidValueError) }) }) describe("key", () => { it("accept hex or raw bytes and normalise to lowercase hex", () => { expect(key(`0x${"AB".repeat(32)}`).value).toBe(KEY) expect(key(new Uint8Array(32).fill(0xab)).value).toBe(KEY) }) it("reject anything that is not exactly 32 bytes", () => { expect(() => key(`0x${"ab".repeat(31)}`)).toThrow(/31 bytes/) expect(() => key(new Uint8Array(31))).toThrow(/not exactly 32 bytes/) expect(() => key("not hex" as `0x${string}`)).toThrow(/not 0x-prefixed hex/) }) }) describe("bytes", () => { it("accepts raw bytes or hex, and holds lowercase hex", () => { expect(bytes(new Uint8Array([0xde, 0xad])).value).toBe("0xdead") expect(bytes("0xDEAD").value).toBe("0xdead") expect(bytes(new Uint8Array()).value).toBe("0x") }) it("takes hex as the bytes it spells, never as text", () => { expect(bytes("0xab").value).toBe("0xab") }) it("rejects text, odd-length hex, and anything over 128 KiB", () => { expect(() => bytes("hello" as `0x${string}`)).toThrow(/not 0x-prefixed hex/) expect(() => bytes("0xabc")).toThrow(/odd number of hex digits/) expect(bytes(new Uint8Array(MAX_BYTES_LENGTH)).type).toBe("bytes") expect(() => bytes(new Uint8Array(MAX_BYTES_LENGTH + 1))).toThrow(/131073 bytes exceeds/) }) }) describe("dual-mode constructors", () => { const constructors = { bool, i32, u64, u256, dec, bytes, str, addr, key } as const it("describe a column when called with no arguments", () => { for (const [tag, construct] of Object.entries(constructors)) { const column = (construct as () => unknown)() expect(isColumn(column)).toBe(true) expect(column).toMatchObject({ type: tag, isIndexed: false, isOptional: false }) } }) it("throw for an explicit undefined rather than silently making a column", () => { for (const construct of Object.values(constructors)) { expect(() => (construct as (value: unknown) => unknown)(undefined)).toThrow(InvalidValueError) } }) it("make a value, not a column, when given one", () => { expect(isColumn(dec("4.1"))).toBe(false) expect(dec("4.1")).toEqual(dec("4.10")) }) }) describe("toValue", () => { it("gives each bare form its unambiguous default type", () => { expect(toValue(true)).toEqual(bool(true)) expect(toValue(10)).toEqual(i32(10)) expect(toValue(10n)).toEqual(u256(10n)) expect(toValue("hello")).toEqual(str("hello")) expect(toValue(new Uint8Array([1]))).toEqual(bytes("0x01")) }) it("rejects a column where a value belongs, saying which call was meant", () => { expect(() => toValue(dec() as unknown as string)).toThrow(/a column, not a value/) expect(() => toValue(dec() as unknown as string)).toThrow(/dec\(\.\.\.\) with an argument/) }) it("leaves a hex-looking string a string, rather than guessing at a key", () => { expect(toValue(KEY)).toEqual(str(KEY)) }) it("leaves a tagged value's type and value alone", () => { for (const tagged of [ dec("3.5"), i32(-42), u256(10n), str("hi"), bool(true), key(KEY), bytes(KEY), ]) { expect(toValue(tagged)).toEqual(tagged) } }) it("re-validates a tagged value rather than trusting its shape", () => { // The `validated` brand is type-level only, so nothing at runtime distinguishes a real u256 // from an object that looks like one — out of JSON.parse, a config file, or an `as any`. Left // unchecked this was silent corruption, not an error: the ABI encoder would take the *string* // "1000" and write its UTF-8 bytes into the word, storing 2.27e76 on chain. const lookAlike = { type: "u256", value: "1000" } as unknown as string expect(toValue(lookAlike)).toEqual(u256(1000n)) expect(toValue(lookAlike).value).toBe(1000n) // And a look-alike carrying something its type cannot hold is now rejected outright. expect(() => toValue({ type: "i32", value: 2 ** 40 } as unknown as string)).toThrow( InvalidValueError, ) expect(() => toValue({ type: "addr", value: "0x1234" } as unknown as string)).toThrow( InvalidValueError, ) expect(() => toValue({ type: "str", value: 42 } as unknown as string)).toThrow( InvalidValueError, ) }) it("rejects values it cannot type", () => { expect(() => toValue({ type: "nope", value: 1 } as unknown as string)).toThrow( UntypedValueError, ) expect(() => toValue(new ArrayBuffer(32) as unknown as string)).toThrow(UntypedValueError) // `in` would have walked the prototype chain and accepted these as type tags. for (const inherited of ["constructor", "toString", "valueOf", "hasOwnProperty"]) { expect(() => toValue({ type: inherited, value: 1 } as unknown as string)).toThrow( UntypedValueError, ) } }) it("rejects an absent value by telling you to omit the attribute, not to infer a type", () => { // `undefined` and `null` are not assignable to ValueInput, but they still reach here at // runtime — from a spread of an optional-shaped object, or from data that never saw a type. for (const absent of [undefined, null]) { expect(() => toValue(absent as unknown as string)).toThrow(MissingValueError) expect(() => toValue(absent as unknown as string, "score")).toThrow(/Leave "score" out/) } expect(() => toValue(undefined as unknown as string, "score")).toThrow( /attribute "score" is undefined/, ) }) }) describe("the validated brand", () => { it("makes a hand-written value object a compile error", () => { // @ts-expect-error — only the tagged constructors produce a validated value, which is what // guarantees every value on the wire has been range- and format-checked. const forged: ReturnType<typeof i32> = { type: "i32", value: 3.5 } expect(forged.value).toBe(3.5) }) })