Skip to main content
Understanding how parameters are encoded into calldata is essential for working with smart contracts at a low level.

ABI Encoding Overview

The Contract ABI (Application Binary Interface) defines how to encode function calls and data. Every parameter is encoded to exactly 32 bytes (256 bits), with specific rules for different types.

Encoding Structure

[4 bytes: selector] + [32 bytes per static param] + [dynamic data]
Static parameters: Fixed-size types encoded in-place Dynamic parameters: Variable-size types with pointer + data

Basic Type Encoding

Integers (uint/int)

Integers are left-padded with zeros to 32 bytes:
import { Uint256 } from '@tevm/voltaire';

// uint256(42)
const value = Uint256.from(42n);
console.log(value.toHex());
// 0x000000000000000000000000000000000000000000000000000000000000002a
Smaller integer types follow the same padding:
// uint8(255)
// 0x00000000000000000000000000000000000000000000000000000000000000ff

// uint128(1000)
// 0x00000000000000000000000000000000000000000000000000000000000003e8

Addresses

Addresses are 20 bytes, left-padded to 32 bytes:
import { Address } from '@tevm/voltaire';

const addr = Address("0x70997970C51812dc3A010C7d01b50e0d17dc79C8");
console.log(addr.toBytes());
// 0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8
//   [12 zero bytes][20 address bytes]

Booleans

Booleans encoded as uint256:
  • false0x0000...0000
  • true0x0000...0001
import { CallData, Abi } from '@tevm/voltaire';

const abi = Abi([{
  name: "setValue",
  type: "function",
  inputs: [{ name: "value", type: "bool" }]
}]);

const calldata = abi.setValue.encode(true);
// Selector + 0x0000000000000000000000000000000000000000000000000000000000000001

Fixed-Size Bytes (bytes1-bytes32)

Fixed bytes are right-padded with zeros:
import { Bytes32 } from '@tevm/voltaire';

// bytes4 selector
const selector = new Uint8Array([0xa9, 0x05, 0x9c, 0xbb]);
// Encoded as:
// 0xa9059cbb000000000000000000000000000000000000000000000000000000000000
//   [4 bytes][28 zero bytes]

// bytes32 (no padding needed)
const hash = Bytes32.from("0x1234...");
// 0x1234... (32 bytes exactly)

Dynamic Type Encoding

Dynamic types (strings, bytes, arrays) use offset-based encoding:
  1. Static section contains offset pointer (32 bytes)
  2. Offset points to start of dynamic data
  3. Dynamic data starts with length, followed by content

Dynamic Bytes

const abi = Abi([{
  name: "setData",
  type: "function",
  inputs: [{ name: "data", type: "bytes" }]
}]);

const data = new Uint8Array([0x12, 0x34, 0x56]);
const calldata = abi.setData.encode(data);

// Result:
// 0x36a58863  (selector)
//   0000000000000000000000000000000000000000000000000000000000000020  (offset: 32)
//   0000000000000000000000000000000000000000000000000000000000000003  (length: 3)
//   1234560000000000000000000000000000000000000000000000000000000000  (data, right-padded)
Offset: Points to where length starts (byte 32 after selector) Length: Number of bytes in the data Data: Right-padded to 32-byte boundary

Strings

Strings encoded identically to bytes:
const abi = Abi([{
  name: "setName",
  type: "function",
  inputs: [{ name: "name", type: "string" }]
}]);

const calldata = abi.setName.encode("hello");

// 0xc47f0027  (selector)
//   0000000000000000000000000000000000000000000000000000000000000020  (offset)
//   0000000000000000000000000000000000000000000000000000000000000005  (length: 5)
//   68656c6c6f000000000000000000000000000000000000000000000000000000  ("hello", right-padded)

Arrays

Fixed-Size Arrays

Fixed arrays encoded like multiple static parameters:
const abi = Abi([{
  name: "setValues",
  type: "function",
  inputs: [{ name: "values", type: "uint256[3]" }]
}]);

const calldata = abi.setValues.encode([1n, 2n, 3n]);

// 0x... (selector)
//   0000000000000000000000000000000000000000000000000000000000000001  (values[0])
//   0000000000000000000000000000000000000000000000000000000000000002  (values[1])
//   0000000000000000000000000000000000000000000000000000000000000003  (values[2])

Dynamic Arrays

Dynamic arrays use offset + length + elements:
const abi = Abi([{
  name: "setValues",
  type: "function",
  inputs: [{ name: "values", type: "uint256[]" }]
}]);

const calldata = abi.setValues.encode([1n, 2n]);

// 0x... (selector)
//   0000000000000000000000000000000000000000000000000000000000000020  (offset)
//   0000000000000000000000000000000000000000000000000000000000000002  (length: 2)
//   0000000000000000000000000000000000000000000000000000000000000001  (values[0])
//   0000000000000000000000000000000000000000000000000000000000000002  (values[1])

Complex Encoding

Multiple Parameters

With multiple parameters, dynamic types use offsets relative to start of parameters section:
const abi = Abi([{
  name: "transfer",
  type: "function",
  inputs: [
    { name: "to", type: "address" },       // Static
    { name: "amount", type: "uint256" },   // Static
    { name: "data", type: "bytes" }        // Dynamic
  ]
}]);

const calldata = abi.transfer.encode(
  Address("0x70997970C51812dc3A010C7d01b50e0d17dc79C8"),
  TokenBalance.fromUnits("1", 18),
  new Uint8Array([0xab, 0xcd])
);

// 0x... (selector: 4 bytes)
//   00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8  (to: 32 bytes)
//   0000000000000000000000000000000000000000000000000de0b6b3a7640000  (amount: 32 bytes)
//   0000000000000000000000000000000000000000000000000000000000000060  (offset to data: 96 bytes from start)
//   0000000000000000000000000000000000000000000000000000000000000002  (data length)
//   abcd000000000000000000000000000000000000000000000000000000000000  (data)
Offset calculation: Skip static params (2 * 32 = 64) + 32 for offset itself = 96 bytes (0x60)

Structs (Tuples)

Structs encoded as if all fields were separate parameters:
const abi = Abi([{
  name: "swap",
  type: "function",
  inputs: [{
    name: "params",
    type: "tuple",
    components: [
      { name: "tokenIn", type: "address" },
      { name: "tokenOut", type: "address" },
      { name: "amountIn", type: "uint256" }
    ]
  }]
}]);

const params = {
  tokenIn: Address("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"),
  tokenOut: Address("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"),
  amountIn: TokenBalance.fromUnits("1000", 6)
};

const calldata = abi.swap.encode(params);

// 0x... (selector)
//   0000000000000000000000000000000000000000000000000000000000000020  (tuple offset)
//   000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48  (tokenIn)
//   000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2  (tokenOut)
//   00000000000000000000000000000000000000000000000000000000003d0900  (amountIn)

Nested Arrays

Arrays of dynamic types require nested offsets:
const abi = Abi([{
  name: "multicall",
  type: "function",
  inputs: [{ name: "calls", type: "bytes[]" }]
}]);

const calls = [
  new Uint8Array([0x12, 0x34]),
  new Uint8Array([0x56, 0x78, 0x9a])
];

const calldata = abi.multicall.encode(calls);

// 0x... (selector)
//   0000000000000000000000000000000000000000000000000000000000000020  (array offset)
//   0000000000000000000000000000000000000000000000000000000000000002  (array length: 2)
//   0000000000000000000000000000000000000000000000000000000000000040  (offset to calls[0])
//   0000000000000000000000000000000000000000000000000000000000000080  (offset to calls[1])
//   0000000000000000000000000000000000000000000000000000000000000002  (calls[0] length)
//   1234000000000000000000000000000000000000000000000000000000000000  (calls[0] data)
//   0000000000000000000000000000000000000000000000000000000000000003  (calls[1] length)
//   56789a0000000000000000000000000000000000000000000000000000000000  (calls[1] data)

Manual Encoding (Zig)

const std = @import("std");
const primitives = @import("primitives");

pub fn encodeTransfer(
    allocator: std.mem.Allocator,
    to: primitives.Address,
    amount: primitives.Uint256,
) !primitives.CallData {
    // Compute selector
    const signature = "transfer(address,uint256)";
    var hash = try primitives.Keccak256.hashString(signature);
    const selector = hash.bytes[0..4].*;

    // Allocate calldata buffer
    var data = std.ArrayList(u8){};
    defer data.deinit(allocator);

    // Write selector
    try data.appendSlice(allocator, &selector);

    // Write address (left-padded to 32 bytes)
    var addr_buf: [32]u8 = [_]u8{0} ** 32;
    @memcpy(addr_buf[12..32], &to.bytes);
    try data.appendSlice(allocator, &addr_buf);

    // Write uint256
    try data.appendSlice(allocator, &amount.bytes);

    return primitives.CallData{
        .data = try data.toOwnedSlice(allocator),
    };
}

Encoding Rules Summary

TypeSizePaddingNotes
uint<N>32 bytesLeft (zeros)N ∈ [8, 256] step 8
int<N>32 bytesLeft (sign-extended)Negative values
address32 bytesLeft (zeros)20-byte value
bool32 bytesLeft (zeros)0 or 1
bytes<N>32 bytesRight (zeros)N ∈ [1, 32]
bytesVariableRight (zeros)Offset + length + data
stringVariableRight (zeros)UTF-8 encoded
T[]Variable-Offset + length + elements
T[k]k × 32 bytes-Fixed array inline
tupleSum of fields-Encoded as struct

Decoding Process

Decoding reverses the encoding:
import { CallData, Abi } from '@tevm/voltaire';

function decodeTransfer(calldata: CallData) {
  const abi = Abi([{
    name: "transfer",
    type: "function",
    inputs: [
      { name: "to", type: "address" },
      { name: "amount", type: "uint256" }
    ]
  }]);

  const decoded = CallData.decode(calldata, abi);

  // Extract parameters
  const to: Address = decoded.parameters[0];
  const amount: Uint256 = decoded.parameters[1];

  return { to, amount };
}

Validation

Always validate encoded calldata:
import { CallData } from '@tevm/voltaire';

function validateCallData(calldata: CallData): boolean {
  // Must have at least selector
  if (calldata.length < 4) {
    return false;
  }

  // Must be 32-byte aligned after selector
  if ((calldata.length - 4) % 32 !== 0) {
    return false;
  }

  return true;
}

Gas Optimization Tips

  1. Use smaller types: uint96 instead of uint256 when possible
  2. Minimize dynamic data: Fixed arrays cheaper than dynamic
  3. Pack structs: Group small values to reduce padding
  4. Order parameters: Put dynamic types last to simplify offsets
// Expensive: 3 separate uint256 parameters = 96 bytes
function transfer(uint256 a, uint256 b, uint256 c) external;

// Cheaper: Pack into single uint256 if values fit
function transferPacked(uint256 packed) external;
// Unpack: a = packed >> 128, b = (packed >> 64) & mask, c = packed & mask

See Also