candid-decoder
Version:
Typeless candid decode
713 lines (670 loc) • 23.7 kB
text/typescript
// candidDecoder.ts
import { Principal } from "@dfinity/principal";
/**
* Custom error class for Candid decoding issues.
* Includes the byte index where the error occurred.
*/
export class CandidError extends Error {
constructor(
message: string,
public index: number,
) {
super(message);
this.name = "CandidError";
}
}
/**
* Interface for the result returned by the decodeCandid function.
*/
export type DecodeResult =
| { ok: any }
| { warning: { data: any; msg: string; index: number } }
| { error: { data?: any; msg: string; index: number } };
/**
* A utility class for reading bytes from a Uint8Array buffer.
* Keeps track of the current reading offset and provides various read methods.
*/
class ByteReader {
private offset: number = 0;
private dataView: DataView; // For reading multi-byte numbers (e.g., floats, fixed-size integers)
constructor(private buffer: Uint8Array) {
this.dataView = new DataView(
buffer.buffer,
buffer.byteOffset,
buffer.byteLength,
);
}
/**
* Checks if there are more bytes to read in the buffer.
* @returns True if more bytes are available, false otherwise.
*/
hasMore(): boolean {
return this.offset < this.buffer.length;
}
/**
* Reads a single byte from the current offset and advances the offset.
* @returns The byte read.
* @throws CandidError if attempting to read beyond the buffer end.
*/
readByte(): number {
if (!this.hasMore()) {
throw new CandidError(
"Unexpected end of buffer when reading a single byte.",
this.offset,
);
}
const byte = this.buffer[this.offset];
this.offset++;
return byte;
}
/**
* Reads a specified number of bytes from the current offset and advances the offset.
* @param length The number of bytes to read.
* @returns A Uint8Array containing the read bytes.
* @throws CandidError if not enough bytes are available to read the specified length.
*/
readBytes(length: number): Uint8Array {
if (this.offset + length > this.buffer.length) {
throw new CandidError(
`Not enough bytes to read. Expected ${length}, but only ${this.buffer.length - this.offset} available.`,
this.offset,
);
}
const bytes = this.buffer.slice(this.offset, this.offset + length);
this.offset += length;
return bytes;
}
/**
* Reads a LEB128 encoded unsigned integer (used for 'nat' and lengths).
* @returns The decoded unsigned integer as a BigInt.
*/
readULEB128(): bigint {
let result = 0n;
let shift = 0n;
let byte;
do {
byte = this.readByte();
result |= (BigInt(byte) & 0x7fn) << shift; // Take lower 7 bits and append
shift += 7n;
} while ((byte & 0x80) !== 0); // Continue if MSB is set
return result;
}
/**
* Reads a LEB128 encoded signed integer (used for 'int' and type codes).
* @returns The decoded signed integer as a BigInt.
*/
readSLEB128(): bigint {
let result = 0n;
let shift = 0n;
let byte;
do {
byte = this.readByte();
result |= (BigInt(byte) & 0x7fn) << shift;
shift += 7n;
} while ((byte & 0x80) !== 0);
// Sign extension: If the last byte's MSB (of the 7 bits) was set (0x40),
// and we haven't filled up a 64-bit BigInt yet, extend the sign.
if ((byte & 0x40) !== 0 && shift < 64n) {
// Check for MSB of last 7-bit chunk
result |= ~0n << shift; // Extend sign to fill higher bits
}
return result;
}
/**
* Reads a UTF-8 string of a specified length.
* @param length The length of the string in bytes.
* @returns The decoded string.
* @throws CandidError if not enough bytes are available.
*/
readUtf8String(length: number): string {
const bytes = this.readBytes(length);
const decoder = new TextDecoder("utf-8", { fatal: true });
return decoder.decode(bytes);
}
/**
* Reads a fixed-size little-endian integer (e.g., nat16, int32, nat64).
* @param byteLength The number of bytes to read (1, 2, 4, or 8).
* @param signed Whether the number should be interpreted as signed.
* @returns The decoded integer as a BigInt (important for 64-bit numbers).
* @throws CandidError if not enough bytes are available.
*/
readLittleEndian(byteLength: 1 | 2 | 4 | 8, signed: boolean = false): bigint {
if (this.offset + byteLength > this.buffer.length) {
throw new CandidError(
`Not enough bytes for ${byteLength}-byte number (little-endian)`,
this.offset,
);
}
let value = 0n;
for (let i = 0; i < byteLength; i++) {
value |= BigInt(this.buffer[this.offset + i]) << BigInt(8 * i);
}
this.offset += byteLength;
// Sign extension for fixed-width signed integers
if (signed) {
const msbMask = 1n << BigInt(byteLength * 8 - 1); // Mask for the most significant bit
if ((value & msbMask) !== 0n) {
// If MSB is set, it's a negative number
value = value - (1n << BigInt(byteLength * 8)); // Subtract 2^(numBits) to get negative value
}
}
return value;
}
/**
* Reads a floating-point number (Float32 or Float64).
* @param byteLength The number of bytes to read (4 for Float32, 8 for Float64).
* @returns The decoded floating-point number.
* @throws CandidError if not enough bytes are available.
*/
readFloat(byteLength: 4 | 8): number {
if (this.offset + byteLength > this.buffer.length) {
throw new CandidError(
`Not enough bytes for Float${byteLength * 8}`,
this.offset,
);
}
// Use DataView directly for efficient float reading
let value: number;
if (byteLength === 4) {
value = this.dataView.getFloat32(this.offset, true); // true for little-endian
} else {
value = this.dataView.getFloat64(this.offset, true); // true for little-endian
}
this.offset += byteLength;
return value;
}
/**
* Gets the current reading offset (byte index) in the buffer.
* @returns The current offset.
*/
getCurrentOffset(): number {
return this.offset;
}
}
/**
* Enum representing Candid primitive type codes (negative values).
* These are used in the Candid binary format to denote type definitions.
*/
enum CandidTypeTag {
Null = -1,
Bool = -2,
Nat = -3,
Int = -4,
Nat8 = -5,
Nat16 = -6,
Nat32 = -7,
Nat64 = -8,
Int8 = -9,
Int16 = -10,
Int32 = -11,
Int64 = -12,
Float32 = -13,
Float64 = -14,
Text = -15,
Reserved = -16,
Empty = -17, // Placeholder, usually no encoded value
// Composite Type Definition Codes (these appear in the type table)
Opt = -18,
Vec = -19,
Record = -20,
Variant = -21,
Func = -22,
Service = -23,
Principal = -24, // Principal is also a composite type, but encoded like a primitive value
}
/**
* Base interface for all Candid type definitions parsed from the type table.
*/
interface CandidTypeDefinition {
tag: CandidTypeTag | number; // Can be a primitive tag or a positive type table index (recursive)
}
/**
* Type definition for a Candid Record.
*/
interface RecordTypeDefinition extends CandidTypeDefinition {
tag: CandidTypeTag.Record;
// Fields are sorted by their ID (hash) in increasing order.
fields: { id: bigint; typeIdx: bigint }[]; // id is the field hash, typeIdx is its type's index in typeTable
}
/**
* Type definition for a Candid Variant.
*/
interface VariantTypeDefinition extends CandidTypeDefinition {
tag: CandidTypeTag.Variant;
// Options are sorted by their ID (hash) in increasing order.
options: { id: bigint; typeIdx: bigint }[]; // id is the option hash, typeIdx is its type's index in typeTable
}
/**
* Type definition for a Candid Option.
*/
interface OptTypeDefinition extends CandidTypeDefinition {
tag: CandidTypeTag.Opt;
innerTypeIdx: bigint; // The type index of the value contained within the Option
}
/**
* Type definition for a Candid Vector.
*/
interface VecTypeDefinition extends CandidTypeDefinition {
tag: CandidTypeTag.Vec;
elementTypeIdx: bigint; // The type index of elements within the Vector
}
/**
* Type definition for a Candid Func.
*/
interface FuncTypeDefinition extends CandidTypeDefinition {
tag: CandidTypeTag.Func;
argTypeIdxs: bigint[]; // Type indices of function arguments
retTypeIdxs: bigint[]; // Type indices of function return values
modes: string[]; // e.g., ['query'], ['oneway']
}
/**
* Type definition for a Candid Service.
*/
interface ServiceTypeDefinition extends CandidTypeDefinition {
tag: CandidTypeTag.Service;
// Methods mapping method name string to its function type index.
methods: { name: string; funcTypeIdx: bigint }[];
}
/**
* Creates a lookup map from an array of string field names.
* Each string name is converted to its hash and used as the key.
* @param names An array of string field names.
* @returns A Record where keys are hashes and values are the original field names.
*/
export function createNameLookup(names: string[]): Record<number, string> {
const map: Record<number, string> = {};
for (const name of names) {
const hash = fieldHash(name);
if (map[hash]) {
map[hash] += `|${name}`;
console.warn(
"Hash collision or duplicate between field names:",
map[hash],
);
} else {
map[hash] = name;
}
}
return map;
}
function fieldHash(name: string): number {
const utf8 = new TextEncoder().encode(name);
const p = 223; // The prime base for the polynomial hash
const mod = 2 ** 32; // The modulus, ensuring the hash remains a 32-bit unsigned integer
let hash = 0; // Initialize the hash accumulator
// Iterate over each byte in the UTF-8 encoded string
for (let i = 0; i < utf8.length; i++) {
const c = utf8[i]; // Get the current byte value
// Apply the polynomial hash formula: (previous_hash * base + current_byte) % modulus
hash = (hash * p + c) % mod;
}
// The hash is already within the 32-bit unsigned range due to the modulo operation
return hash;
}
/**
* Main function to decode Candid binary data.
* It parses the Candid header (magic, type table, argument types)
* and then decodes the actual values.
*
* @param buffer The Uint8Array containing the Candid binary data.
* @param fieldNamesMap Optional map of field IDs (bigint) to human-readable names (string).
* Used to resolve numeric field IDs in records/variants to actual names.
* @returns A DecodeResult object containing the decoded data, or error details.
*/
export function decodeCandid(
buffer: Uint8Array,
fieldNamesMap?: Record<number, string>,
): DecodeResult {
const reader = new ByteReader(buffer);
let decodedData: any = null;
let errorMsg: string | null = null;
let errorIdx: number | null = null;
// Stores parsed type definitions, accessible by their index (0-based)
const typeTable: CandidTypeDefinition[] = [];
try {
// --- 1. Read Magic bytes "DIDL" (0x44 0x49 0x44 0x4C) ---
const magic = reader.readBytes(4);
if (
magic[0] !== 0x44 ||
magic[1] !== 0x49 ||
magic[2] !== 0x44 ||
magic[3] !== 0x4c
) {
throw new CandidError("Invalid Candid magic bytes. Expected 'DIDL'.", 0);
}
// --- 2. Parse Type Table ---
// This section defines all custom (composite) types used in the message.
const numberOfTypes = reader.readULEB128();
for (let i = 0; i < numberOfTypes; i++) {
const currentParseOffset = reader.getCurrentOffset(); // Keep track for error reporting
const typeCode = reader.readSLEB128(); // Reads the type tag for the current type definition
let typeDef: CandidTypeDefinition;
switch (Number(typeCode)) {
case CandidTypeTag.Opt:
const optInnerTypeIdx = reader.readSLEB128();
typeDef = {
tag: CandidTypeTag.Opt,
innerTypeIdx: optInnerTypeIdx,
} as OptTypeDefinition;
break;
case CandidTypeTag.Vec:
const vecElementTypeIdx = reader.readSLEB128();
typeDef = {
tag: CandidTypeTag.Vec,
elementTypeIdx: vecElementTypeIdx,
} as VecTypeDefinition;
break;
case CandidTypeTag.Record:
const numRecordFields = reader.readULEB128();
const recordFields: { id: bigint; typeIdx: bigint }[] = [];
for (let j = 0; j < numRecordFields; j++) {
const id = reader.readULEB128(); // Field ID (hash of field name)
const typeIdx = reader.readSLEB128(); // Type index of the field's value
recordFields.push({ id, typeIdx });
}
// Candid spec requires record fields to be sorted by their ID for canonical representation.
recordFields.sort((a, b) => Number(a.id - b.id));
typeDef = {
tag: CandidTypeTag.Record,
fields: recordFields,
} as RecordTypeDefinition;
break;
case CandidTypeTag.Variant:
const numVariantOptions = reader.readULEB128();
const variantOptions: { id: bigint; typeIdx: bigint }[] = [];
for (let j = 0; j < numVariantOptions; j++) {
const id = reader.readULEB128(); // Option ID (hash of option name)
const typeIdx = reader.readSLEB128(); // Type index of the option's value
variantOptions.push({ id, typeIdx });
}
// Candid spec requires variant options to be sorted by their ID for canonical representation.
variantOptions.sort((a, b) => Number(a.id - b.id));
typeDef = {
tag: CandidTypeTag.Variant,
options: variantOptions,
} as VariantTypeDefinition;
break;
case CandidTypeTag.Func:
const numFuncArgs = reader.readULEB128();
const funcArgs: bigint[] = [];
for (let j = 0; j < numFuncArgs; j++)
funcArgs.push(reader.readSLEB128());
const numFuncRets = reader.readULEB128();
const funcRets: bigint[] = [];
for (let j = 0; j < numFuncRets; j++)
funcRets.push(reader.readSLEB128());
const numFuncModes = reader.readULEB128();
const funcModes: string[] = [];
for (let j = 0; j < numFuncModes; j++) {
const modeByte = reader.readByte();
// 1: oneway, 2: query
if (modeByte === 1) funcModes.push("oneway");
else if (modeByte === 2) funcModes.push("query");
else
throw new CandidError(
`Invalid function mode byte: ${modeByte}. Expected 1 or 2.`,
reader.getCurrentOffset() - 1,
);
}
typeDef = {
tag: CandidTypeTag.Func,
argTypeIdxs: funcArgs,
retTypeIdxs: funcRets,
modes: funcModes,
} as FuncTypeDefinition;
break;
case CandidTypeTag.Service:
const numServiceMethods = reader.readULEB128();
const serviceMethods: { name: string; funcTypeIdx: bigint }[] = [];
for (let j = 0; j < numServiceMethods; j++) {
const nameLen = Number(reader.readULEB128());
const name = reader.readUtf8String(nameLen);
const funcTypeIdx = reader.readSLEB128(); // Points to a FuncTypeDefinition in the type table
serviceMethods.push({ name, funcTypeIdx });
}
typeDef = {
tag: CandidTypeTag.Service,
methods: serviceMethods,
} as ServiceTypeDefinition;
break;
default:
throw new CandidError(
`Unexpected type code (${typeCode}) in type table definition at index ${i}.`,
currentParseOffset,
);
}
typeTable.push(typeDef);
}
// --- 3. Parse Message Argument Types ---
const numberOfArgTypes = reader.readULEB128();
const argTypeIndices: bigint[] = [];
for (let i = 0; i < numberOfArgTypes; i++) {
const typeIdx = reader.readSLEB128();
const typeCode = Number(typeIdx);
// Reference types cannot be used directly as arguments.
if (
typeCode === CandidTypeTag.Service ||
typeCode === CandidTypeTag.Func
) {
throw new CandidError(
`Reference type ${typeCode} cannot be used directly as an argument. It must be in the type table.`,
reader.getCurrentOffset() - 1,
);
}
argTypeIndices.push(typeIdx);
}
// --- 4. Decode Values ---
const decodedValues: any[] = [];
for (let i = 0; i < argTypeIndices.length; i++) {
const typeOrIndex = argTypeIndices[i];
decodedValues.push(
decodeValue(reader, typeOrIndex, typeTable, fieldNamesMap),
);
}
// --- 5. Check for trailing bytes ---
if (reader.hasMore()) {
throw new CandidError(
"Trailing bytes found after decoding all expected values.",
reader.getCurrentOffset(),
);
}
decodedData = decodedValues;
return { ok: decodedData };
} catch (e) {
// Centralized error handling
if (e instanceof CandidError) {
errorMsg = e.message;
errorIdx = e.index;
} else if (e instanceof Error) {
errorMsg = `An unexpected JavaScript error occurred: ${e.message}`;
errorIdx = reader.getCurrentOffset(); // Best guess for location
} else {
errorMsg = "An unknown error occurred during decoding.";
errorIdx = reader.getCurrentOffset();
}
return {
error: {
msg: errorMsg,
index: errorIdx,
data: decodedData !== null ? decodedData : undefined,
},
};
}
}
/**
* Helper function to decode a principal value from the byte stream.
* @param reader The ByteReader instance.
* @returns The decoded Principal object.
*/
function decodePrincipalValue(reader: ByteReader): Principal {
const principalTag = reader.readByte();
if (principalTag !== 0x01) {
throw new CandidError(
`Unsupported principal tag: ${principalTag}. Expected 1.`,
reader.getCurrentOffset() - 1,
);
}
const principalLen = Number(reader.readULEB128());
if (principalLen > 29) {
throw new CandidError(
`Invalid principal length: ${principalLen}. Must be at most 29 bytes.`,
reader.getCurrentOffset() - 1,
);
}
const bytes = reader.readBytes(principalLen);
return Principal.fromUint8Array(bytes);
}
/**
* Recursively decodes a Candid value based on its type tag or type table index.
*/
function decodeValue(
reader: ByteReader,
typeOrIndex: bigint,
typeTable: CandidTypeDefinition[],
fieldNamesMap?: Record<number, string>,
): any {
let currentTypeTag: number;
let typeDef: CandidTypeDefinition | undefined;
if (typeOrIndex >= 0) {
if (typeOrIndex >= typeTable.length) {
throw new CandidError(
`Type index ${typeOrIndex} is out of bounds for type table of size ${typeTable.length}.`,
reader.getCurrentOffset(),
);
}
typeDef = typeTable[Number(typeOrIndex)];
currentTypeTag = Number(typeDef.tag);
} else {
currentTypeTag = Number(typeOrIndex);
}
switch (currentTypeTag) {
case CandidTypeTag.Null:
return null;
case CandidTypeTag.Bool:
const boolByte = reader.readByte();
if (boolByte === 0) return false;
if (boolByte === 1) return true;
throw new CandidError(
`Invalid boolean value: ${boolByte}. Expected 0 (false) or 1 (true).`,
reader.getCurrentOffset() - 1,
);
case CandidTypeTag.Nat:
return reader.readULEB128();
case CandidTypeTag.Int:
return reader.readSLEB128();
case CandidTypeTag.Nat8:
return reader.readByte();
case CandidTypeTag.Nat16:
return Number(reader.readLittleEndian(2, false));
case CandidTypeTag.Nat32:
return Number(reader.readLittleEndian(4, false));
case CandidTypeTag.Nat64:
return reader.readLittleEndian(8, false);
case CandidTypeTag.Int8:
return Number(reader.readLittleEndian(1, true));
case CandidTypeTag.Int16:
return Number(reader.readLittleEndian(2, true));
case CandidTypeTag.Int32:
return Number(reader.readLittleEndian(4, true));
case CandidTypeTag.Int64:
return reader.readLittleEndian(8, true);
case CandidTypeTag.Float32:
return reader.readFloat(4);
case CandidTypeTag.Float64:
return reader.readFloat(8);
case CandidTypeTag.Text:
const textLength = Number(reader.readULEB128());
return reader.readUtf8String(textLength);
case CandidTypeTag.Reserved:
return null;
case CandidTypeTag.Empty:
throw new CandidError(
"Attempted to decode 'empty' type as a value. This type has no encoded form.",
reader.getCurrentOffset(),
);
case CandidTypeTag.Principal:
case CandidTypeTag.Service:
return decodePrincipalValue(reader);
case CandidTypeTag.Opt: {
const optDef = typeDef as OptTypeDefinition;
const presentByte = reader.readByte();
if (presentByte === 0) {
return [];
} else if (presentByte === 1) {
return [
decodeValue(reader, optDef.innerTypeIdx, typeTable, fieldNamesMap),
];
} else {
throw new CandidError(
`Invalid option tag: ${presentByte}. Expected 0 or 1.`,
reader.getCurrentOffset() - 1,
);
}
}
case CandidTypeTag.Vec: {
const vecDef = typeDef as VecTypeDefinition;
const vectorLength = Number(reader.readULEB128());
const elements: any[] = [];
for (let i = 0; i < vectorLength; i++) {
elements.push(
decodeValue(reader, vecDef.elementTypeIdx, typeTable, fieldNamesMap),
);
}
return elements;
}
case CandidTypeTag.Record: {
const recordDef = typeDef as RecordTypeDefinition;
const record: { [key: string]: any } = {};
for (const field of recordDef.fields) {
const fieldKey =
fieldNamesMap?.[Number(field.id)] || `_${field.id.toString()}`;
record[fieldKey] = decodeValue(
reader,
field.typeIdx,
typeTable,
fieldNamesMap,
);
}
return record;
}
case CandidTypeTag.Variant: {
const variantDef = typeDef as VariantTypeDefinition;
const variantIdx = Number(reader.readULEB128());
if (variantIdx >= variantDef.options.length) {
throw new CandidError(
`Variant option index ${variantIdx} out of bounds. Variant has ${variantDef.options.length} options.`,
reader.getCurrentOffset() - 1,
);
}
const selectedOption = variantDef.options[variantIdx];
const variantValue = decodeValue(
reader,
selectedOption.typeIdx,
typeTable,
fieldNamesMap,
);
const optionKey =
fieldNamesMap?.[Number(selectedOption.id)] ||
`_${selectedOption.id.toString()}`;
return { [optionKey]: variantValue };
}
case CandidTypeTag.Func: {
const funcTag = reader.readByte();
if (funcTag !== 0x01) {
throw new CandidError(
`Unsupported function tag: ${funcTag}. Expected 1 for reference type.`,
reader.getCurrentOffset() - 1,
);
}
const principal = decodePrincipalValue(reader);
const funcMethodNameLen = Number(reader.readULEB128());
const funcMethodName = reader.readUtf8String(funcMethodNameLen);
return [principal, funcMethodName];
}
default:
throw new CandidError(
`Unsupported or unknown Candid type tag: ${currentTypeTag} (resolved from index ${typeOrIndex}).`,
reader.getCurrentOffset(),
);
}
}