@arkiv-network/sdk
Version:
TypeScript client library for Arkiv's blockchains interactions
176 lines (160 loc) • 8.29 kB
text/typescript
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}`
}