@arkiv-network/sdk
Version:
TypeScript client library for Arkiv's blockchains interactions
258 lines (241 loc) • 9.04 kB
text/typescript
import { bytesToString, type Hex, hexToBytes, pad, size, stringToBytes, toHex } from "viem"
import { unitsToDecimal } from "./decimal"
import { InvalidValueError, UnknownAttributeTypeError } from "./errors"
import {
type ArkivValue,
INDEXED_FLAG,
makeValue,
type StrValue,
TYPE_IDS,
type TypeTag,
} from "./types"
import { addr, bool, dec, decUnits, i32, key, u64, u256 } from "./values"
function strFromChain(value: string): StrValue {
return makeValue("str", value)
}
/**
* The `typeId` marking a tombstone — an attribute being unset. It carries a zero-length value and
* is legal only in a patch's mutation list, never in a create.
*/
const TOMBSTONE_TYPE_ID = 0
/**
* The node's `bytes32` type, which this SDK does not expose. Its values are 32 bytes, as a `key`'s
* are, so they are read as `key`s.
*/
const BYTES32_TYPE_ID = 6
const BYTES32_TAG = "bytes32"
/**
* An attribute in its ABI form: `(Ident32 name, uint8 typeId, bytes value)`.
*
* The low seven bits of `typeId` select the type, and bit 7 ({@link INDEXED_FLAG}) says whether
* the node indexes the attribute. The `value` encoding is selected by the type: an exact 32-byte
* word for the word types, the raw bytes for `str` and `bytes`, and zero-length for a tombstone.
*/
export type AbiAttribute = {
name: Hex
typeId: number
value: Hex
}
/**
* Encodes a typed value into the `bytes` the ABI carries for it.
*
* This and {@link decodeValueBytes} are the only place the value/wire mapping is written down.
*
* - Word types (`bool`, `i32`, `u64`, `u256`, `dec`, `addr`, `key`) become exactly 32
* bytes, in the encoding Solidity would use — right-aligned, and sign-extended for `i32`.
* - `str` becomes its raw UTF-8 bytes, and `bytes` its raw bytes: variable length, no padding.
*/
export function encodeValueBytes(value: ArkivValue): Hex {
switch (value.type) {
case "bool":
return pad(value.value ? "0x01" : "0x00", { size: 32 })
case "i32":
// int32 is sign-extended across the whole word, as Solidity encodes it.
return toHex(BigInt.asUintN(256, BigInt(value.value)), { size: 32 })
case "u64":
case "u256":
return toHex(value.value, { size: 32 })
case "dec":
return toHex(BigInt.asUintN(256, decUnits(value)), { size: 32 })
case "key":
return value.value
case "addr":
return pad(value.value.toLowerCase() as Hex, { size: 32 })
case "str":
return toHex(stringToBytes(value.value))
case "bytes":
return value.value
}
}
/**
* Decodes the ABI `bytes` of an attribute of type `tag`.
*
* Rejects anything a well-formed encoder would not have produced — a word type whose value is not
* 32 bytes, an `i32` that is not sign-extended, a `bool` that is neither 0 nor 1 — rather than
* silently truncating it.
*/
export function decodeValueBytes(tag: TypeTag, value: Hex): ArkivValue {
if (tag === "str") return strFromChain(bytesToString(hexToBytes(value)))
if (tag === "bytes") return makeValue("bytes", value.toLowerCase() as Hex)
const width = size(value)
if (width !== 32) {
throw new InvalidValueError(tag, value, `a ${tag} value must be 32 bytes, got ${width}`)
}
const raw = BigInt(value)
switch (tag) {
case "bool":
if (raw !== 0n && raw !== 1n) {
throw new InvalidValueError("bool", value, "a bool word must hold exactly 0 or 1")
}
return bool(raw === 1n)
case "i32": {
const narrowed = BigInt.asIntN(32, raw)
if (BigInt.asUintN(256, narrowed) !== raw) {
throw new InvalidValueError("i32", value, "an i32 word must be sign-extended")
}
return i32(narrowed)
}
case "u64":
if (raw >> 64n !== 0n) {
throw new InvalidValueError("u64", value, "a u64 word must be zero-padded to 8 bytes")
}
return u64(raw)
case "u256":
return u256(raw)
case "dec":
return dec(unitsToDecimal(BigInt.asIntN(256, raw)))
case "key":
return key(value)
case "addr": {
if (raw >> 160n !== 0n) {
throw new InvalidValueError(
"addr",
value,
"an address word must be zero-padded to 20 bytes",
)
}
return addr(`0x${value.slice(-40)}`)
}
}
}
/**
* Encodes a named, typed attribute into its ABI form.
*
* @param name - The attribute name. Assumed already validated.
* @param indexed - Whether the node should index it. Never true for `bytes`, which the engine
* rejects with the flag set.
* @throws {InvalidValueError} If asked to index a `bytes` value.
*/
export function encodeAbiAttribute(
name: string,
value: ArkivValue,
indexed: boolean,
): AbiAttribute {
if (indexed && value.type === "bytes") {
throw new InvalidValueError("bytes", `<${name}>`, "bytes are stored, never indexed")
}
return {
// Ident32 is left-aligned and null-padded, which is what `toHex` does for a string.
name: toHex(name, { size: 32 }),
typeId: TYPE_IDS[value.type] | (indexed ? INDEXED_FLAG : 0),
value: encodeValueBytes(value),
}
}
/**
* Splits a wire `typeId` into its type and indexed flag.
*
* The node's `bytes32` (typeId 6) decodes as `key`.
*
* @throws {UnknownAttributeTypeError} If the low seven bits name no known type.
*/
export function decodeTypeId(typeId: number): { tag: TypeTag; indexed: boolean } {
const base = typeId & ~INDEXED_FLAG
const tag =
base === BYTES32_TYPE_ID
? "key"
: (Object.keys(TYPE_IDS) as TypeTag[]).find((candidate) => TYPE_IDS[candidate] === base)
if (tag === undefined) throw new UnknownAttributeTypeError(typeId)
return { tag, indexed: (typeId & INDEXED_FLAG) !== 0 }
}
/** Encodes a tombstone — the mutation that unsets `name`. Legal only in a patch. */
export function encodeTombstone(name: string): AbiAttribute {
return { name: toHex(name, { size: 32 }), typeId: TOMBSTONE_TYPE_ID, value: "0x" }
}
/**
* Decodes an attribute value as it comes back over JSON-RPC.
*
* The JSON encoding follows the value's **declared type**, never its magnitude: `bool` is a JSON
* boolean, `i32` a JSON number, `u64`/`u256` a `0x` QUANTITY, `dec` a decimal string, `str` a
* string, and
* the byte-shaped types `0x` DATA. Decimal strings are also accepted for the integer types, so this
* keeps working against a node that renders them that way.
*
* @param type - The type tag the response declared, e.g. `"u256"`.
* @param value - The JSON value.
* @throws {UnknownAttributeTypeError} If the tag names no known type.
* @throws {InvalidValueError} If the value does not parse as that type.
*/
export function decodeRpcValue(type: string, value: unknown): ArkivValue {
const tag = asTypeTag(type)
switch (tag) {
case "bool":
return bool(asBoolean(value))
case "i32":
return i32(Number(asIntegerish("i32", value)))
case "u64":
return u64(asIntegerish("u64", value))
case "u256":
return u256(asIntegerish("u256", value))
case "dec":
return dec(asString("dec", value))
case "key":
return key(asString("key", value) as Hex)
case "addr":
return addr(asString("addr", value))
case "str":
return strFromChain(asString("str", value))
case "bytes":
return makeValue("bytes", asString("bytes", value).toLowerCase() as Hex)
}
}
/**
* Narrows a type tag as the node spelled it. The node's `bytes32` narrows to `key`.
*
* @throws {UnknownAttributeTypeError} If it names no type this SDK knows.
*/
export function asTypeTag(type: string): TypeTag {
// `Object.hasOwn`, not `in`: `in` walks the prototype chain, so "constructor" and "toString"
// would pass as type tags and then fall off the end of the exhaustive switch below as `undefined`.
if (typeof type !== "string" || !Object.hasOwn(TYPE_IDS, type)) {
if (type === BYTES32_TAG) return "key"
throw new UnknownAttributeTypeError(type)
}
return type as TypeTag
}
function asBoolean(value: unknown): boolean {
if (typeof value === "boolean") return value
// Tolerated because a node may render it as text; anything else is a protocol error.
if (value === "true" || value === "false") return value === "true"
throw new InvalidValueError("bool", value, "not a boolean")
}
function asString(tag: TypeTag, value: unknown): string {
if (typeof value !== "string") {
throw new InvalidValueError(tag, value, `expected a string, got ${typeof value}`)
}
return value
}
/** A `0x` QUANTITY, a JSON number, or a decimal string — all of which name one integer. */
function asIntegerish(tag: TypeTag, value: unknown): bigint {
if (typeof value === "number") {
if (!Number.isInteger(value)) {
throw new InvalidValueError(tag, value, "not an integer")
}
return BigInt(value)
}
if (typeof value === "bigint") return value
if (typeof value === "string") {
if (/^0x[0-9a-fA-F]+$/.test(value)) return BigInt(value)
if (/^-?\d+$/.test(value)) return BigInt(value)
}
throw new InvalidValueError(tag, value, "not an integer")
}