UNPKG

@arkiv-network/sdk

Version:

TypeScript client library for Arkiv's blockchains interactions

663 lines (636 loc) • 24.3 kB
import { type Address, checksumAddress, type Hex, isAddress, isHex, stringToBytes, toHex, } from "viem" import { type AnyColumn, type Column, createColumn, isColumn } from "./column" import { DECIMAL_UNIT, decimalToUnits, unitsToDecimal } from "./decimal" import { InvalidValueError, MissingValueError, UntypedValueError } from "./errors" import { type AddrValue, type ArkivValue, type BoolValue, type BytesValue, type DecValue, type I32Value, isArkivValue, type KeyValue, makeValue, type StrValue, type TypeTag, type U64Value, type U256Value, } from "./types" /** The widest `i32`. */ export const I32_MAX = 2147483647 /** The narrowest `i32`. */ export const I32_MIN = -2147483648 /** The widest `u64`. */ export const U64_MAX = 2n ** 64n - 1n /** The widest `u256`. */ export const U256_MAX = 2n ** 256n - 1n /** * The longest `str`, in UTF-8 bytes */ export const MAX_STRING_BYTES = 128 /** * The longest `bytes` value, in bytes. The cap applies to each `bytes` attribute separately, but * all the `bytes` values of one create or patch also share the `MAX_ENTITY_BYTES` budget. */ export const MAX_BYTES_LENGTH = 128 * 1024 /** * Finds the first lone UTF-16 surrogate — a string the node would reject, since it has no UTF-8 * encoding. `TextEncoder` would quietly replace it with U+FFFD instead. */ function findLoneSurrogate(value: string): number | undefined { for (let index = 0; index < value.length; index++) { const code = value.charCodeAt(index) if (code < 0xd800 || code > 0xdfff) continue const next = value.charCodeAt(index + 1) if (code <= 0xdbff && next >= 0xdc00 && next <= 0xdfff) { index++ continue } return index } return undefined } /** A `bool` column, indexed for equality once `.indexed()`. */ export function bool(): Column<"bool", false, false, false> /** * A boolean. * * This is the default type for a bare boolean, so `{ flagged: true }` and * `{ flagged: bool(true) }` are the same value. * * @example * bool(true) // { type: "bool", value: true } */ export function bool(value: boolean): BoolValue export function bool(...args: [] | [boolean]): BoolValue | Column<"bool", false, false, false> { if (args.length === 0) return createColumn("bool", toTypedValue) const [value] = args if (typeof value !== "boolean") { throw new InvalidValueError("bool", value, "not a boolean") } return makeValue("bool", value) } /** An `i32` column, indexed for equality and range queries once `.indexed()`. */ export function i32(): Column<"i32", false, false, false> /** * A 32-bit signed integer. This is the default type for a bare number, so `{ level: 10 }` and * `{ level: i32(10) }` are the same value. * * @param value - An integer in [-2147483648, 2147483647]. * @throws {InvalidValueError} If the value is not an integer, or does not fit an `i32`. * * @example * i32(10) // { type: "i32", value: 10 } * i32(-42) // negative values are fine — i32 is the signed integer */ export function i32(value: number | bigint): I32Value export function i32( ...args: [] | [number | bigint] ): I32Value | Column<"i32", false, false, false> { if (args.length === 0) return createColumn("i32", toTypedValue) const [value] = args if (typeof value === "bigint") { if (value > BigInt(I32_MAX) || value < BigInt(I32_MIN)) { throw new InvalidValueError("i32", value, outOfI32Range(value >= 0n)) } return makeValue("i32", Number(value)) } if (typeof value !== "number" || !Number.isFinite(value)) { throw new InvalidValueError("i32", value, "not a number") } if (!Number.isInteger(value)) { throw new InvalidValueError( "i32", value, "not an integer", `Use dec(${JSON.stringify(String(value))}) for a fixed-point decimal.`, ) } if (value > I32_MAX || value < I32_MIN) { throw new InvalidValueError("i32", value, outOfI32Range(value >= 0)) } return makeValue("i32", value) } function outOfI32Range(nonNegative: boolean): string { const range = `outside the i32 range [${I32_MIN}, ${I32_MAX}]` return nonNegative ? `${range} — use u64(...) for large counters, timestamps and block heights (Date.now() does ` + `not fit an i32), or u256(...) beyond 2**64 - 1` : `${range} — use dec(...) for values that need more range than an i32` } /** A `u256` column, indexed for equality and range queries once `.indexed()`. */ export function u256(): Column<"u256", false, false, false> /** * A 256-bit unsigned integer. The type for token amounts and anything else that can outgrow 2⁶⁴ — * reach for {@link u64} first for counters, timestamps and block heights. * * @param value - A non-negative integer, as a `bigint`, a safe `number`, or a decimal or `0x` hex * string. * @throws {InvalidValueError} If the value is negative, non-integral, or wider than 256 bits. * * @example * u256(1_000_000n) * u256(Date.now()) // safe integers are accepted * u256("0xf4240") // hex, as it comes back over JSON-RPC */ export function u256(value: bigint | number | string): U256Value export function u256( ...args: [] | [bigint | number | string] ): U256Value | Column<"u256", false, false, false> { if (args.length === 0) return createColumn("u256", toTypedValue) return makeValue("u256", toUnsigned("u256", args[0], U256_MAX)) } /** A `u64` column, indexed for equality and range queries once `.indexed()`. */ export function u64(): Column<"u64", false, false, false> /** * A 64-bit unsigned integer. * * This is the width the protocol counts in: block heights, `$expiresAt` and the entity-nonce are * all `u64`, so it is the type to reach for when a value has to line up with one of them. Use * {@link u256} for token amounts and anything else that can exceed 2⁶⁴. * * @param value - A non-negative integer, as a `bigint`, a safe `number`, or a decimal or `0x` hex * string. * @throws {InvalidValueError} If the value is negative, non-integral, or wider than 64 bits. * * @example * u64(1_200_000n) // a block height * u64(Date.now()) // safe integers are accepted * u64("0x124f80") // hex, as it comes back over JSON-RPC */ export function u64(value: bigint | number | string): U64Value export function u64( ...args: [] | [bigint | number | string] ): U64Value | Column<"u64", false, false, false> { if (args.length === 0) return createColumn("u64", toTypedValue) return makeValue("u64", toUnsigned("u64", args[0], U64_MAX)) } /** * Parses and range-checks an unsigned integer for `tag`, whose widest value is `max`. * * Shared by {@link u64} and {@link u256} so the two differ in exactly one thing — their width — * rather than in how they read a string or how they explain a failure. */ function toUnsigned(tag: "u64" | "u256", value: bigint | number | string, max: bigint): bigint { const parsed = toUnsignedBigint(tag, value) if (parsed < 0n) { throw new InvalidValueError( tag, value, `negative — ${tag} is unsigned`, "Use i32(...) for small signed integers, or dec(...) for a signed fixed-point value.", ) } if (parsed > max) { const bits = tag === "u64" ? 64 : 256 throw new InvalidValueError( tag, value, `wider than ${bits} bits`, tag === "u64" ? "Use u256(...) for values beyond 2**64 - 1." : undefined, ) } return parsed } function toUnsignedBigint(tag: "u64" | "u256", value: bigint | number | string): bigint { if (typeof value === "bigint") return value if (typeof value === "number") { if (!Number.isInteger(value)) { throw new InvalidValueError( tag, value, "not an integer", `Use dec(${JSON.stringify(String(value))}) for a fixed-point decimal.`, ) } if (!Number.isSafeInteger(value)) { throw new InvalidValueError( tag, value, "beyond the safe integer range, so it may already have lost precision", "Pass it as a bigint literal (e.g. 12345678901234567890n) or a string instead.", ) } return BigInt(value) } if (typeof value === "string") { if (!/^(0x[0-9a-fA-F]+|\d+)$/.test(value)) { throw new InvalidValueError(tag, value, "not a decimal or 0x-hex integer") } return BigInt(value) } throw new InvalidValueError(tag, value, "not a bigint, number or string") } /** A `dec` column, indexed for equality and range queries once `.indexed()`. */ export function dec(): Column<"dec", false, false, false> /** * A signed fixed-point decimal with exactly 18 decimal places. * * Pass fractional values as **strings**. A JavaScript number cannot represent most decimals * exactly, so accepting `dec(0.1 + 0.2)` would quietly store `0.30000000000000004`; requiring the * string keeps what you write and what is stored the same thing. Excess precision is likewise an * error rather than a rounding. * * @param value - A decimal string, or an integral `number` / `bigint`. * @throws {InvalidValueError} If the value is malformed, has more than 18 fractional digits, or * falls outside the `int256` range. * * @example * dec("3.5") * dec("-0.000000000000000001") // the smallest step * dec(5) // integers may be passed as a number */ export function dec(value: string | number | bigint): DecValue export function dec( ...args: [] | [string | number | bigint] ): DecValue | Column<"dec", false, false, false> { if (args.length === 0) return createColumn("dec", toTypedValue) const [value] = args if (typeof value === "string") { return makeValue("dec", unitsToDecimal(decimalToUnits(value))) } if (typeof value === "bigint") { return decFromUnits(value * DECIMAL_UNIT) } if (typeof value !== "number" || !Number.isFinite(value)) { throw new InvalidValueError("dec", value, "not a number") } if (!Number.isInteger(value)) { throw new InvalidValueError( "dec", value, "a fractional number cannot be represented exactly in binary floating point", `Pass it as a string instead: dec(${JSON.stringify(String(value))}).`, ) } return decFromUnits(BigInt(value) * DECIMAL_UNIT) } /** * The exact scaled integer behind a `dec` — the value multiplied by 10 ** 18, which is what the * protocol stores and compares. Use it when you need exact arithmetic; JavaScript has no native * decimal type, so the SDK carries the canonical string and leaves the maths to you. * * @example * decUnits(dec("3.5")) // 3500000000000000000n */ export function decUnits(value: DecValue): bigint { return decimalToUnits(value.value) } /** * A `dec` built from its scaled integer units — the inverse of {@link decUnits}. * * @example * decFromUnits(3_500_000_000_000_000_000n) // dec("3.5") */ export function decFromUnits(units: bigint): DecValue { return makeValue("dec", unitsToDecimal(units)) } /** A `str` column, indexed for equality and prefix (`STARTSWITH`) matching once `.indexed()`. */ export function str(): Column<"str", false, false, false> /** * A UTF-8 string of at most 128 bytes. * * The limit is on **bytes**, not characters: a string of 128 emoji does not fit. Any character * is allowed, control characters included. To store raw bytes, use {@link bytes}, or hex in a * `str` when they must be indexed. * * @throws {InvalidValueError} If the value is not a string, holds a lone surrogate (so it has no * UTF-8 encoding), or exceeds 128 UTF-8 bytes. * * @example * str("Bob") */ export function str(value: string): StrValue export function str(...args: [] | [string]): StrValue | Column<"str", false, false, false> { if (args.length === 0) return createColumn("str", toTypedValue) const [value] = args if (typeof value !== "string") { throw new InvalidValueError("str", value, "not a string") } const surrogate = findLoneSurrogate(value) if (surrogate !== undefined) { throw new InvalidValueError( "str", value, `contains a lone surrogate at index ${surrogate}, so it is not valid UTF-8`, ) } const bytes = stringToBytes(value).length if (bytes > MAX_STRING_BYTES) { throw new InvalidValueError( "str", value, `${bytes} UTF-8 bytes exceeds the ${MAX_STRING_BYTES}-byte limit on a str`, "Store the long form in a bytes column and keep a short, queryable key here.", ) } return makeValue("str", value) } /** An `addr` column, indexed for equality once `.indexed()`. */ export function addr(): Column<"addr", false, false, false> /** * A 20-byte Ethereum address. * * All-lowercase and all-uppercase input carries no checksum and is accepted as-is; mixed-case * input is checked against its EIP-55 checksum, so a mistyped address is caught here rather than * being written to a wrong account. The stored value is always EIP-55 checksummed. * * @throws {InvalidValueError} If the value is not 20 hex bytes, or fails its EIP-55 checksum. * * @example * addr("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045") */ export function addr(value: string): AddrValue export function addr(...args: [] | [string]): AddrValue | Column<"addr", false, false, false> { if (args.length === 0) return createColumn("addr", toTypedValue) const [value] = args if (typeof value !== "string" || !/^0x[0-9a-fA-F]{40}$/.test(value)) { throw new InvalidValueError("addr", value, "not a 20-byte 0x-prefixed hex address") } const body = value.slice(2) const cased = body !== body.toLowerCase() && body !== body.toUpperCase() if (cased && !isAddress(value, { strict: true })) { throw new InvalidValueError( "addr", value, "fails its EIP-55 checksum, so a digit is probably mistyped", `If those hex digits are right, the correctly checksummed form is ` + `${checksumAddress(value as Address)}.`, ) } return makeValue("addr", checksumAddress(value as Address)) } /** A `key` column — a reference to another entity — indexed for equality once `.indexed()`. */ export function key(): Column<"key", false, false, false> /** * A reference to another entity. * * References are **weak**: nothing checks that the target exists when you write it, and a * reference to a deleted or expired entity is allowed to dangle. * * @param value - A 32-byte entity key, as `0x` hex or raw bytes. * @throws {InvalidValueError} If the value is not exactly 32 bytes. * * @example * key(parentEntityKey) */ export function key(value: Hex | Uint8Array): KeyValue export function key( ...args: [] | [Hex | Uint8Array] ): KeyValue | Column<"key", false, false, false> { if (args.length === 0) return createColumn("key", toTypedValue) return makeValue("key", asFixedHex("key", args[0], 32)) } /** * A `bytes` column — a blob. Bytes are stored but never indexed, so a bytes column has no * `.indexed()`: it can be selected, not filtered on. A schema may have several, within the * `MAX_ATTRIBUTES` cap on columns — but the `bytes` values of one create or patch share a single * `MAX_ENTITY_BYTES` budget. */ export function bytes(): Column<"bytes", false, false, false> /** * Opaque bytes of any length up to 128 KiB — a document, an image, a serialized object. This is the * default type for a bare `Uint8Array`, so `{ body: buf }` and `{ body: bytes(buf) }` are the same * value. * * @param value - The bytes, raw or as `0x` hex. Hex is taken as the bytes it spells, never as text. * @throws {InvalidValueError} If the value is not bytes or hex, or exceeds 128 KiB. * * @example * bytes(new TextEncoder().encode("# Hello")) * bytes("0xdeadbeef") */ export function bytes(value: Hex | Uint8Array): BytesValue export function bytes( ...args: [] | [Hex | Uint8Array] ): BytesValue | Column<"bytes", false, false, false> { if (args.length === 0) return createColumn("bytes", toTypedValue) const [value] = args const hex = asHex("bytes", value) const length = (hex.length - 2) / 2 if (length > MAX_BYTES_LENGTH) { throw new InvalidValueError( "bytes", `<${length} bytes>`, `${length} bytes exceeds the ${MAX_BYTES_LENGTH}-byte limit on a bytes value`, "Split it across several entities, or store the bulk elsewhere and keep a reference.", ) } return makeValue("bytes", hex) } /** Normalizes hex-or-bytes input of a known width into lowercase `0x` hex. */ function asFixedHex(tag: "key", value: Hex | Uint8Array, size: number): Hex { if (value instanceof Uint8Array) { if (value.length !== size) { throw new InvalidValueError(tag, `<${value.length} bytes>`, `not exactly ${size} bytes`) } return toHex(value) } const hex = asHex(tag, value) if (hex.length !== 2 + size * 2) { throw new InvalidValueError( tag, value, `${(hex.length - 2) / 2} bytes, not exactly ${size} bytes`, ) } return hex } /** Normalizes hex-or-bytes input into lowercase `0x` hex of a whole number of bytes. */ function asHex(tag: "key" | "bytes", value: Hex | Uint8Array): Hex { if (value instanceof Uint8Array) return toHex(value) if (typeof value !== "string" || !isHex(value, { strict: true })) { throw new InvalidValueError(tag, value, "not 0x-prefixed hex") } if (value.length % 2 !== 0) { throw new InvalidValueError(tag, value, "an odd number of hex digits, so not whole bytes") } return value.toLowerCase() as Hex } /** * A JavaScript value accepted wherever a typed value is expected: either a tagged value, or one of * the five bare forms that map unambiguously onto a single type. * * | bare form | type | * | ------------ | ------- | * | `boolean` | `bool` | * | `number` | `i32` | * | `bigint` | `u256` | * | `string` | `str` | * | `Uint8Array` | `bytes` | * * Anything else — a large number, a decimal, an address, an entity key — must name its type, which * is what keeps `"0x1234..."` a string rather than silently becoming a key. * * These defaults apply identically when writing and when querying, so a value written bare and * queried bare always agrees with itself. The mismatch to watch for is a bare value where its * column has another type: a bare `bigint` is a `u256`, not a {@link u64}. Through a schema that is * caught — a write throws `SchemaMismatchError` and a query throws `InvalidPredicateError` — but a * query without a schema sends the predicate as written, and the engine, for which a type mismatch * is not an error, simply matches nothing. */ export type ValueInput = ArkivValue | boolean | number | bigint | string | Uint8Array /** * The type a {@link ValueInput} resolves to — the tag of a tagged value, or the default of a bare * form. `never` for anything else. */ export type TagOf<input> = input extends { readonly type: infer tag extends TypeTag } ? tag : input extends boolean ? "bool" : input extends number ? "i32" : input extends bigint ? "u256" : input extends string ? "str" : input extends Uint8Array ? "bytes" : never /** * The bare JavaScript form that defaults to `tag`, where there is one — `boolean` for `bool`, * `number` for `i32`, `bigint` for `u256`, `string` for `str`, `Uint8Array` for `bytes`. The other * types — `u64`, `dec`, `addr` and `key` — must be written tagged. */ export type BareOf<tag extends TypeTag> = { bool: boolean i32: number u64: never u256: bigint dec: never bytes: Uint8Array str: string addr: never key: never }[tag] /** * Resolves a {@link ValueInput} to a typed value, applying the bare-form defaults above. Tagged * values pass through untouched. * * @param input - The value to resolve. * @param attributeName - The attribute this value belongs to, used to make errors name it. * * @throws {MissingValueError} If the input is `undefined` or `null` — an attribute is either set * with a value and a type, or not set at all. * @throws {UntypedValueError} If the input is not a tagged value or a bare boolean, number, bigint, * string or `Uint8Array`. * @throws {InvalidValueError} If a bare value does not fit the type it defaults to. * * @example * toValue(10) // i32(10) * toValue(10n) // u256(10n) * toValue("hello") // str("hello") * toValue(dec("3.5")) // unchanged */ export function toValue(input: ValueInput, attributeName?: string): ArkivValue { // `undefined` and `null` are rejected before the type switch. They are not assignable to // ValueInput, but they still arrive at runtime — from a spread of an optional-shaped object, or // from data that never went through the type system — and "cannot infer a type for undefined" // does not tell you the fix, which is always to leave the attribute out. if (input === undefined || input === null) { throw new MissingValueError(input, attributeName) } switch (typeof input) { case "boolean": return bool(input) case "number": return i32(input) case "bigint": return u256(input) case "string": return str(input) default: { if (input instanceof Uint8Array) return bytes(input) if (isArkivValue(input)) return revalidate(input) // Widened: a column is not a ValueInput, but it is the one mix-up the dual-mode constructors // invite — `str()` where `str("...")` was meant — so it is named rather than left untyped. const candidate: unknown = input if (isColumn(candidate)) throw columnGivenAsValue(candidate) throw new UntypedValueError(input) } } } /** * Resolves `input` to a value of type `tag` — a tagged value of that type, or whatever `tag`'s * constructor accepts. What a column's `.default(...)` runs its argument through. * * @throws {InvalidValueError} If the input does not make a valid value of type `tag`. */ function toTypedValue(tag: TypeTag, input: unknown): ArkivValue { if (isArkivValue(input)) { const value = revalidate(input) if (value.type !== tag) { throw new InvalidValueError(tag, input, `a ${value.type} value, not a ${tag}`) } return value } if (isColumn(input)) throw columnGivenAsValue(input) // Through the constructor, so every type accepts exactly what its constructor does. const construct = CONSTRUCTORS[tag] as (value: unknown) => ArkivValue return construct(input) } function columnGivenAsValue(column: AnyColumn): InvalidValueError { return new InvalidValueError( column.type, `<${column.type} column>`, "a column, not a value", `Pass the value itself, e.g. ${column.type}(...) with an argument — ${column.type}() with ` + "none declares a schema column.", ) } /** Each tag's constructor, called with a value. */ const CONSTRUCTORS: { readonly [tag in TypeTag]: (value: never) => ArkivValue } = { bool, i32, u64, u256, dec, bytes, str, addr, key, } /** * Re-runs a tagged value through its own constructor. * * {@link isArkivValue} can only check shape — the `validated` brand is a type-level construct with * no runtime existence, so a hand-written `{ type: "u256", value: "1000" }` (from `JSON.parse`, a * config file, an `as any` boundary) is indistinguishable from a real `u256(1000n)`. Passing it * through unchecked was silent corruption rather than an error: the ABI encoder would take the * *string* "1000" and write its UTF-8 bytes into the word, storing 2.27e76 on chain. * * Running the constructor again is what makes the brand's promise — "every value crossing the wire * has been range-, length- and format-checked" — true at runtime. For a value that really did come * from a constructor it is a no-op that returns an equal value. */ function revalidate(value: ArkivValue): ArkivValue { switch (value.type) { case "bool": return bool(value.value) case "i32": return i32(value.value) case "u64": return u64(value.value) case "u256": return u256(value.value) case "dec": return dec(value.value) case "bytes": return bytes(value.value) case "str": return str(value.value) case "addr": return addr(value.value) case "key": return key(value.value) } }