@biconomy/abstractjs
Version:
SDK for Biconomy integration with support for account abstraction, smart accounts, ERC-4337.
402 lines • 19.6 kB
JavaScript
// If anyone wanted to get comfortable with ABI encoding specification
// Check this out: https://docs.soliditylang.org/en/develop/abi-spec.html
// If you are a video person ?
// Check this out: https://www.youtube.com/watch?v=upVloLUw5Z0
// Author Information:
// This code is created and audited by Venkatesh Rajendran
// Github: https://github.com/vr16x
// Reachout to me if there is any issues or doubts
// X: https://x.com/vr16x
// Slack: https://biconomyworkspace.slack.com/team/U08HJN728RM
import { AbiEncodingArrayLengthMismatchError, AbiEncodingBytesSizeMismatchError, AbiEncodingLengthMismatchError, BaseError, IntegerOutOfRangeError, InvalidAbiEncodingTypeError, InvalidAddressError, InvalidArrayError, boolToHex, concat, isAddress, numberToHex, padHex, size, slice, stringToHex, toFunctionSelector } from "viem";
import { InputParamFetcherType, isRuntimeComposableValue } from "./composabilityCalls.js";
// Total list of int and uint types used for regex matching against the data type
const integerRegex = /^(u?int)(8|16|24|32|40|48|56|64|72|80|88|96|104|112|120|128|136|144|152|160|168|176|184|192|200|208|216|224|232|240|248|256)?$/;
// length in hex (0x....5) + Right padded string hex (0x4j34ng2....0000)
// Example: 0x00000.....50x4j34ng2....0000
const encodeString = (value) => {
const hexValue = stringToHex(value);
const partsLength = Math.ceil(size(hexValue) / 32);
const parts = [];
for (let i = 0; i < partsLength; i++) {
parts.push(padHex(slice(hexValue, i * 32, (i + 1) * 32), {
dir: "right"
}));
}
return {
dynamic: true,
data: [
concat([padHex(numberToHex(size(hexValue), { size: 32 })), ...parts]) // Concat string len + right padded string hex value
]
};
};
// The encoding for bytes also treated same as string if it is a bytes value
// Example: 0x00000.....50x4j34ng2....0000
// Encoding for static bytes type is straight forward. It will be simply converted into hex with padding on right
// No length is used for static bytes type
// 0x50x4j34ng2....00000000
const encodeBytes = (value, { param }) => {
const [, paramSize] = param.type.split("bytes");
// Checks if the value is runtime value which means the appropriate encoding already happened in composability integration
// In this case, we just need to determine whether the bytes is a static or dynamic value
if (isRuntimeComposableValue(value)) {
if (paramSize) {
return { dynamic: false, data: [value] };
}
// if there is no param size, it is a dynamic value
// calculate the length of the InputParams and push it as the first InputParam
const inputParamsLength = getRuntimeValueLength(value.inputParams);
const firstInputParam = {
fetcherType: InputParamFetcherType.RAW_BYTES,
paramData: numberToHex(inputParamsLength, { size: 32 }),
constraints: []
};
value.inputParams = [firstInputParam, ...value.inputParams];
return { dynamic: true, data: [value] };
}
const bytesSize = size(value);
// If there is no param size, it is bytes data type and treated as dynamic value
if (!paramSize) {
let value_ = value;
// If the size is not divisible by 32 bytes, pad the end
// with empty bytes to the ceiling 32 bytes.
if (bytesSize % 32 !== 0)
value_ = padHex(value_, {
dir: "right",
size: Math.ceil((value.length - 2) / 2 / 32) * 32
});
return {
dynamic: true,
data: [padHex(numberToHex(bytesSize, { size: 32 })), value_] // Length + Value
};
}
// Check for param size which is extracted from type with actual byte size
if (bytesSize !== Number.parseInt(paramSize))
throw new AbiEncodingBytesSizeMismatchError({
expectedSize: Number.parseInt(paramSize),
value: value
});
return { dynamic: false, data: [padHex(value, { dir: "right" })] }; // No length because of the static nature
};
// Number value is converted into 32 bytes hex
// Example: 0x000...0000002
const encodeNumber = (value, { signed, size = 256 }) => {
// Validating the boundary of uint and int types
if (typeof size === "number") {
const max = BigInt(2) ** (BigInt(size) - (signed ? BigInt(1) : BigInt(0))) - BigInt(1);
const min = signed ? -max - BigInt(1) : BigInt(0);
if (value > max || value < min)
throw new IntegerOutOfRangeError({
max: max.toString(),
min: min.toString(),
signed,
size: size / 8,
value: value.toString()
});
}
// Simply converting a number into hex value
return {
dynamic: false,
data: [
numberToHex(value, {
size: 32,
signed
})
]
};
};
// Boolean value is converted into 32 bytes hex
// Example: 0x000...0000001 for true
// Example: 0x000...0000000 for false
const encodeBool = (value) => {
if (typeof value !== "boolean")
throw new BaseError(`Invalid boolean value: "${value}" (type: ${typeof value}). Expected: \`true\` or \`false\`.`);
// Simply converting a bool into hex value
return { dynamic: false, data: [padHex(boolToHex(value))] };
};
// Address value is converted into 32 bytes hex
// Example: 0x000...g132gj1 for false
const encodeAddress = (value) => {
if (!isAddress(value))
throw new InvalidAddressError({ address: value });
// Simply converting a address into hex value
return { dynamic: false, data: [padHex(value.toLowerCase())] };
};
const encodeArray = (value, { length, param }) => {
// If there is no length mentioned in the array, it is a dynamic array
const dynamic = length === null;
// this will revert if the value provided is not an array
// it can in theory be an array of runtime values!
if (!Array.isArray(value))
throw new InvalidArrayError(value);
// If there is a length specified, the static array length is validated with its elements count
if (!dynamic && value.length !== length)
throw new AbiEncodingArrayLengthMismatchError({
expectedLength: length,
givenLength: value.length,
type: `${param.type}[${length}]`
});
let dynamicChild = false;
const preparedParams = [];
for (let i = 0; i < value.length; i++) {
// The internal elements are encoded according to its data type.
const preparedParam = prepareParam({ param, value: value[i] });
// If any internal data type is dynamic, the array will be treated as dynamic
// No matter what whether the main array is static or dynamic
if (preparedParam.dynamic)
dynamicChild = true;
preparedParams.push(preparedParam);
}
// if the array itself dynamic or child element is dynamic ? The array is treated as dynamic
if (dynamic || dynamicChild) {
// Encoding the internal elements
const data = encodeParams(preparedParams);
// If the main array itself dynamic and has a atleast one element, the encoding will be
// length + list of elements appended one after other based on the internal data type encoding
// If it is a empty dynamic arrray ? zero length is added as encoding
if (dynamic) {
const length = numberToHex(preparedParams.length, { size: 32 });
return {
dynamic: true,
data: preparedParams.length > 0 ? [length, ...data] : [length] // The entire array will be placed at the tail
};
}
// If the main array is not dynamic but the child is dynamic ? The child element encoding already
// handled the length + data while encoding itself. So no need for length here.
// Array will be placed in head but the element pointer is stored here which points to the values in tail
if (dynamicChild)
return { dynamic: true, data: data };
}
// As the encoding for array can be nested as well. But finally we will flatten them
const data = preparedParams.flatMap(({ data }) => data);
// If the array is static, the elements are placed in the head with 32 bytes size per element
return {
dynamic: false,
data: data
};
};
// Static struct usually handled same as static data type and placed in head
// A struct with dynamic data type will be considered as dynamic in nature
const encodeTuple = (value, { param }) => {
let dynamic = false;
const preparedParams = [];
for (let i = 0; i < param.components.length; i++) {
const param_ = param.components[i];
const index = Array.isArray(value) ? i : param_.name;
// The internal elements will be encoded based on its data type. It will handle the nested data type encoding as well
const preparedParam = prepareParam({
param: param_,
value: value[index]
});
preparedParams.push(preparedParam);
// If any of the internal element of a tuple is dynamic ? The entire tuple is treated as dynamic tuple
if (preparedParam.dynamic)
dynamic = true;
}
return {
dynamic,
data: dynamic
? encodeParams(preparedParams) // If the struct is dynamic, it will be placed in tail with a pointer/offset in head
: preparedParams.flatMap(({ data }) => data) // If the tuple is static, it is simply placed on after another in head
};
};
const getArrayComponents = (type) => {
const matches = type.match(/^(.*)\[(\d+)?\]$/);
return matches
? // Return `null` if the array is dynamic.
[matches[2] ? Number(matches[2]) : null, matches[1]]
: undefined; // not an array
};
const encodeParams = (preparedParams) => {
// 1. Compute the size of the STATIC part of the parameters.
let staticSize = 0;
for (let i = 0; i < preparedParams.length; i++) {
const { dynamic, data } = preparedParams[i];
// If the data type is dynamic, only offset/pointer is placed in the head
// hence it only requires 32 bytes for head where the actual values will be placed in tail
if (dynamic) {
staticSize += 32;
}
else {
// STATIC ARGUMENT
// Most probably the size of this data array will be one for this instance.
// However, for arrays, it can be more than one.
// Calculate the length for all the `data` elements, which are values of Hex or RuntimeValue
// this length will be used to properly calculate the length of the whole static section
const len = data.reduce((acc, val) => {
// if `val` is a RuntimeValue, in theory it can contain both STATIC_CALL and RAW_BYTES InputParams
// calculate the length for all the inputParams
if (isRuntimeComposableValue(val)) {
// val can only be a RuntimeValue in this `if` block
const inputParamsLength = getRuntimeValueLength(val.inputParams);
return acc + inputParamsLength;
}
// if it is not a RuntimeValue, it is a Hex value. So we just add its length to the accumulator
return acc + size(val);
}, 0);
staticSize += len;
}
}
// 2. Split the parameters into static and dynamic parts.
const staticParams = [];
const dynamicParams = [];
let dynamicSize = 0;
for (let i = 0; i < preparedParams.length; i++) {
const { dynamic, data } = preparedParams[i];
// If this is a DYNAMIC ARGUMENT, we will place a offset in head (static section) and the argument itself is placed to the tail
if (dynamic) {
// Calculate and push the offset
// For the first dynamic param, there will be no dynamic value in tail. Which means the dynamic value will be place after all head elements
// length of all static type are calculated and dynamic value is placed after the calculated length
// From the next time, the static + dynamic length will be calculated to get a fresh pointer at the last where the dynamic value will be placed
staticParams.push(numberToHex(staticSize + dynamicSize, { size: 32 }));
// go over `data` array entries and for each of them calculate the length, then accumulate the length
// this length will be used to calculate the offset for the next dynamic value
// `data` is a list of Hex values or RuntimeValues. can contain more than one element
const len = data.reduce((acc, val) => {
// if `val` is a RuntimeValue, in theory it can contain both STATIC_CALL and RAW_BYTES InputParams
// calculate the length for all the inputParams
if (isRuntimeComposableValue(val)) {
// val can only be a RuntimeValue in this `if` block
const inputParamsLength = getRuntimeValueLength(val.inputParams);
return acc + inputParamsLength;
}
// if it is not a RuntimeValue, it is a Hex value. So we just add its length to the accumulator
return acc + size(val);
}, 0);
// push the `data` items into `dynamicParams` list
dynamicParams.push(...data);
// Dynamic length is calculated. It will increase as the number of dynamic values present
// For every dynamic argument, the pointer placed in head will sum the static size and existing dynamic values to find/point a new place after all
// existing dynamic arguments
dynamicSize += len;
}
else {
// if it is a STATIC ARGUMENT, just push the data into staticParams list
// its length has already been accumulated in `staticSize`
staticParams.push(...data);
}
}
// 3. Concatenate static and dynamic parts.
// Static params are placed in head and dynamic params are placed in tail
return [...staticParams, ...dynamicParams];
};
const prepareParams = ({ params, values }) => {
const preparedParams = [];
for (let i = 0; i < params.length; i++) {
preparedParams.push(prepareParam({ param: params[i], value: values[i] }));
}
return preparedParams;
};
const prepareParam = ({ param, value }) => {
const runtimeValue = { dynamic: false, data: [value] };
// Detect whether the data type is array or not
const arrayComponents = getArrayComponents(param.type);
if (arrayComponents) {
// If it is array, the length might be some number or null.
// Null => Dynamic array, Some number => Static array
const [length, type] = arrayComponents;
// Runtime value is not required to be handled. As the internal types will be the runtime and it will be auto handled when
// handling the internal fields
return encodeArray(value, { length, param: { ...param, type } });
}
if (param.type === "address") {
// If the address is runtime value, the encoding is already happened in composability helper, simply return the runtime value
if (isRuntimeComposableValue(value))
return runtimeValue;
return encodeAddress(value);
}
if (param.type === "bool") {
// If the bool is runtime value, the encoding is already happened in composability helper, simply return the runtime value
if (isRuntimeComposableValue(value))
return runtimeValue;
return encodeBool(value);
}
if (param.type.startsWith("uint") || param.type.startsWith("int")) {
// If the uint/int is runtime value, the encoding is already happened in composability helper, simply return the runtime value
if (isRuntimeComposableValue(value))
return runtimeValue;
const signed = param.type.startsWith("int");
const [, , size = "256"] = integerRegex.exec(param.type) ?? [];
return encodeNumber(value, {
signed,
size: Number(size)
});
}
if (param.type.startsWith("bytes")) {
// Runtime value is handled inside this function itself
return encodeBytes(value, { param });
}
if (param.type === "string") {
// If the string is runtime value, the encoding is already happened in composability helper, simply return the runtime value
if (isRuntimeComposableValue(value))
return runtimeValue;
return encodeString(value);
}
if (param.type === "tuple") {
// Runtime value is not required to be handled. As the internal types will be the runtime and it will be auto handled when
// handling the internal fields
return encodeTuple(value, {
param: param
});
}
// If none of the type matches, invalid type is specified for encoding, so throw an error
throw new InvalidAbiEncodingTypeError(param.type, {
docsPath: "/docs/contract/encodeAbiParameters"
});
};
export const encodeRuntimeFunctionData = (inputs, args) => {
// If there is no arguments to the function, no need for encoding at all.
if (!inputs || inputs.length === 0) {
return ["0x"];
}
// If the required inputs and arguments passed is not same, throw an error
if (inputs.length !== args.length) {
throw new AbiEncodingLengthMismatchError({
expectedLength: inputs.length,
givenLength: args.length
});
}
// Prepare the encoding
const preparedParams = prepareParams({
params: inputs,
values: args
});
// Encoding the prepared data types based on static and dynamic natrue
const data = encodeParams(preparedParams);
// If there is no data, which means no encoding happened, return 0x
if (data.length === 0)
return ["0x"];
// Return theb encoded data. This is not a usual encoding function which returns the funSig + args encoding as a hex
// It returns only arguments in a array form which is very flexible to handle runtime values
return data;
};
export const getFunctionContextFromAbi = (functionSig, abi) => {
if (abi.length === 0) {
throw new Error("Invalid ABI");
}
const [functionInfo] = abi.filter((item) => item.type === "function" && item.name === functionSig);
if (!functionInfo) {
throw new Error(`${functionSig} not found on the ABI`);
}
const { inputs, name, outputs, stateMutability } = functionInfo;
return {
inputs,
name,
outputs,
functionType: ["view", "pure"].includes(stateMutability) ? "read" : "write",
functionSig: toFunctionSelector(functionInfo)
};
};
export const getRuntimeValueLength = (inputParams) => {
return inputParams.reduce((acc, inputParam) => {
// if it is a STATIC_CALL, we can not know the size beforehand
// so we will assume it is 32 bytes, as we do not expect non-static types to be used as runtime values
if (inputParam.fetcherType === InputParamFetcherType.STATIC_CALL) {
return acc + 32;
}
// if it is a RAW_BYTES, the length is the length of the paramData
return acc + size(inputParam.paramData);
}, 0);
};
//# sourceMappingURL=runtimeAbiEncoding.js.map