UNPKG

@arkiv-network/sdk

Version:

TypeScript client library for Arkiv's blockchains interactions

176 lines (160 loc) • 8.29 kB
import { BaseError, ContractFunctionRevertedError, type Hex, hexToString } from "viem" // Internal seam: the typeId layout is the protocol's, not part of the package's public surface. import { decodeTypeId } from "../schema/codec" /** * Turns a decoded engine revert into a sentence that says what to change. * * viem decodes the error and then renders it positionally — `Ident32InvalidByte(uint256 position, * bytes1 value)` arrives as `(5, 0x41)`, which is the right data and no help at all. The engine's * errors carry enough to name the actual problem, so this spends the args rather than printing * them. * * @param error - Anything thrown by a write action. * @returns The explanation, or `undefined` if this was not a decodable engine revert — in which * case the caller should fall back to whatever the transport said. */ export function describeEntityRevert(error: unknown): string | undefined { if (!(error instanceof BaseError)) return undefined const reverted = error.walk((e) => e instanceof ContractFunctionRevertedError) if (!(reverted instanceof ContractFunctionRevertedError)) return undefined const name = reverted.data?.errorName if (name === undefined) return undefined const args = (reverted.data?.args ?? []) as readonly unknown[] return explain(name, args) ?? `the engine rejected the batch with ${name}` } /** The attribute-name charset, quoted the way an error message should read. */ const CHARSET = '"A"-"Z", "a"-"z", "0"-"9", ".", "-" and "_", with a letter first' function explain(name: string, args: readonly unknown[]): string | undefined { switch (name) { // ── attribute names ────────────────────────────────────────────────────── case "Ident32InvalidByte": { const position = Number(args[0] ?? 0) const char = printable(String(args[1] ?? "0x00")) return ( `an attribute name holds ${char} at byte ${position}, which is outside the ` + `name charset (${CHARSET})` ) } case "Ident32Empty": return "an attribute name is empty" // ── the attribute list ─────────────────────────────────────────────────── case "SystemAttributeNotWritable": return ( `"${identName(args[0])}" is owned by the engine and cannot be written. Names starting ` + 'with "$" are reserved for system attributes' ) case "TombstoneInCreate": return ( `a create cannot unset "${identName(args[0])}" — nothing exists yet, so leave the name ` + "out instead" ) case "TombstoneValueNotEmpty": return `the tombstone for "${identName(args[0])}" carries a value; unsetting takes no value` case "InvalidValueType": return describeInvalidType(identName(args[0]), Number(args[1])) case "AttributesNotSorted": return "attributes must be strictly ascending by name, which also makes each name unique" case "TooManyAttributes": return `${String(args[0])} attributes exceeds the limit of ${String(args[1])}` case "EmptyMutations": return `the patch of ${short(args[0])} has no mutations, so it would do nothing` // ── entity state and authorisation ─────────────────────────────────────── case "EntityNotFound": return `no entity ${short(args[0])} — it may have been deleted, or have expired` case "EntityExpired": return `entity ${short(args[0])} expired at block ${String(args[1])}` case "NotOwner": return `entity ${short(args[0])} is owned by ${String(args[2])}, not ${String(args[1])}` case "ReadOnlyEntity": return ( `entity ${short(args[0])} was created readonly, so its contents can never change. Its ` + "expiry can still be extended, and it can be transferred or deleted" ) case "ReservedCreationFlags": return ( `creation flags 0b${Number(args[0] ?? 0) .toString(2) .padStart(8, "0")} set a reserved ` + "bit; only readonly and permissionlessExtension are defined" ) // ── expiry ─────────────────────────────────────────────────────────────── case "ExpiryDeadOnArrival": return ( `the expiry resolves to block ${String(args[0])}, which is not after the current block ` + `${String(args[1])}` ) case "ExpiryBeyondMaxLifetime": return ( `the expiry resolves to block ${String(args[0])}, more than the chain's maximum lifetime ` + `of ${String(args[2])} blocks after the current block ${String(args[1])}` ) case "ExpiryNotExtended": return ( `entity ${short(args[0])} already expires at block ${String(args[2])}, so extending it ` + `to ${String(args[1])} would shorten its life` ) // ── ownership ──────────────────────────────────────────────────────────── case "TransferToZeroAddress": return `entity ${short(args[0])} cannot be transferred to the zero address` case "TransferToSelf": return `entity ${short(args[0])} is already owned by that account` // ── batch framing ──────────────────────────────────────────────────────── case "EmptyBatch": return "the batch has no operations" case "InvalidOpType": return `operation tag ${String(args[0])} is not one the engine knows` case "NonCanonicalOperationData": return ( `the payload for operation tag ${String(args[0])} is not its canonical encoding, so the ` + "engine refused it rather than accept a second spelling of the same operation" ) default: return undefined } } /** * Why the engine refused a `typeId` — an indexed `bytes`, a known type it would not take (most * likely the indexed flag, on a node that predates it), or a type it does not know. */ function describeInvalidType(name: string, typeId: number): string { try { const { tag, indexed } = decodeTypeId(typeId) if (tag === "bytes" && indexed) { return `"${name}" is a bytes attribute flagged as indexed, and bytes are stored but never indexed` } if (indexed) { return ( `"${name}" has type ${tag} and is flagged as indexed (type id ${typeId}), which the node ` + "does not accept. The node may not support the indexed flag yet" ) } return `"${name}" has type ${tag} (type id ${typeId}), which the node does not accept` } catch { // Falls through to the generic reading: the low bits name no type the SDK knows either. } return `"${name}" names type id ${typeId}, which is not a known type` } /** A `bytes32` Ident32 cell as the name it holds — left-aligned, null-padded. */ function identName(raw: unknown): string { if (typeof raw !== "string") return String(raw) try { // Cut at the first null rather than trimming with a regex: the padding is whatever follows // the name, so everything before it is the name and nothing after it is. Written as an escape // because a literal NUL byte in source is invisible to every reader after you. return hexToString(raw as Hex).split("\u0000")[0] ?? "" } catch { return raw } } /** An entity key, abbreviated */ function short(raw: unknown): string { const text = String(raw) return text.length > 18 ? `${text.slice(0, 10)}…${text.slice(-6)}` : text } /** A `bytes1` as something readable: the character it is, plus the byte it was. */ function printable(byte: string): string { const code = Number.parseInt(byte.replace(/^0x/, ""), 16) if (!Number.isFinite(code)) return `byte ${byte}` const char = String.fromCharCode(code) return code >= 0x21 && code <= 0x7e ? `"${char}" (${byte})` : `byte ${byte}` }