Разработка декодировщика транзакций (human-readable)
Пользователь подтверждает транзакцию в MetaMask, видит поле data: 0xa9059cbb000000000000000000000000... — и нажимает «Подтвердить», потому что доверяет dApp. Это проблема всего Web3 UX. Нормальный декодировщик показывает: «Transfer 100 USDC to 0xABC...». Wallet Guard, Rabby Wallet, WalletConnect — все они уже решают это. Задача: встроить аналогичную возможность в ваш dApp или инструмент.
Анатомия transaction data
EVM calldata имеет фиксированную структуру:
0xa9059cbb ← function selector (4 bytes)
000000000000000000000000742d35cc6634c0532925a3b8d2985e2b0e6d38e ← address (32 bytes)
00000000000000000000000000000000000000000000152d02c7e14af6800000 ← uint256 (32 bytes)
Function selector — первые 4 байта keccak256 от сигнатуры функции. keccak256("transfer(address,uint256)") → 0xa9059cbb. База данных селекторов: 4byte.directory (7M+ сигнатур) — основа декодирования неизвестных контрактов.
Стратегии декодирования
1. Декодирование через ABI (известный контракт)
Самый точный вариант. ABI точно описывает типы и имена параметров:
import { decodeFunctionData, parseAbi } from "viem"
const erc20Abi = parseAbi([
"function transfer(address to, uint256 amount)",
"function approve(address spender, uint256 amount)",
"function transferFrom(address from, address to, uint256 amount)"
])
function decodeERC20Call(data: `0x${string}`) {
try {
const { functionName, args } = decodeFunctionData({ abi: erc20Abi, data })
return { functionName, args }
} catch {
return null
}
}
// Результат: { functionName: "transfer", args: ["0xAddress", 100000000n] }
2. Декодирование через 4byte.directory API
Контракт незнакомый, ABI нет — ищем по selector:
async function lookupSelector(selector: string): Promise<string[]> {
const response = await fetch(
`https://www.4byte.directory/api/v1/signatures/?hex_signature=${selector}`
)
const data = await response.json()
return data.results.map(r => r.text_signature)
// ["transfer(address,uint256)", "transfer(address,uint256)"]
// Может быть несколько совпадений (коллизии)
}
async function decodeUnknownCalldata(data: `0x${string}`) {
const selector = data.slice(0, 10) // "0x" + 8 hex chars
const signatures = await lookupSelector(selector)
for (const sig of signatures) {
try {
const abi = parseAbi([`function ${sig}`])
const decoded = decodeFunctionData({ abi, data })
return { signature: sig, args: decoded.args }
} catch {
continue // пробуем следующий вариант при коллизии
}
}
return null
}
3. Etherscan API для верифицированных контрактов
Etherscan хранит ABI верифицированных контрактов:
async function fetchContractABI(contractAddress: string): Promise<any[] | null> {
const url = `https://api.etherscan.io/api?module=contract&action=getabi` +
`&address=${contractAddress}&apikey=${ETHERSCAN_KEY}`
const response = await fetch(url)
const data = await response.json()
if (data.status !== "1") return null
return JSON.parse(data.result)
}
Кешировать обязательно: ABI не меняется, TTL можно ставить бесконечный.
4. Proxy contract resolution
Многие контракты используют proxy паттерны (EIP-1967, EIP-1822, OpenZeppelin TransparentProxy). ABI прокси неинформативен — нужно найти implementation:
import { createPublicClient, http } from "viem"
async function resolveImplementation(proxyAddress: `0x${string}`): Promise<`0x${string}` | null> {
const client = createPublicClient({ chain: mainnet, transport: http(RPC_URL) })
// EIP-1967: implementation slot
const EIP1967_SLOT = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc"
const slotValue = await client.getStorageAt({
address: proxyAddress,
slot: EIP1967_SLOT
})
if (!slotValue || slotValue === "0x" + "0".repeat(64)) return null
// Берём последние 20 байт (адрес)
return `0x${slotValue.slice(-40)}` as `0x${string}`
}
Для полного resolution нужно проверить несколько слотов (EIP-1967, EIP-1822, Gnosis Safe slot, кастомные).
Human-readable форматирование
Декодировать мало — нужно отображать понятно. Параметры типа uint256 для ERC-20 нужно конвертировать с учётом decimals:
interface HumanReadableParam {
name: string
type: string
value: string // всегда строка для отображения
rawValue: unknown
}
async function humanizeParam(
name: string,
type: string,
value: unknown
): Promise<HumanReadableParam> {
if (type === "address") {
const address = value as string
const ensName = await resolveENS(address) // 0xABC... → vitalik.eth
return {
name, type,
value: ensName || formatAddress(address), // "0x1234...5678"
rawValue: value
}
}
if (type === "uint256" && name.toLowerCase().includes("amount")) {
// Пытаемся получить decimals если передан token address в соседних параметрах
return { name, type, value: formatBigInt(value as bigint), rawValue: value }
}
if (type === "bytes") {
const bytes = value as string
// Рекурсивно декодируем если это вложенный вызов
const nested = await tryDecodeNested(bytes as `0x${string}`)
return { name, type, value: nested || bytes, rawValue: value }
}
return { name, type, value: String(value), rawValue: value }
}
ENS resolution — важная деталь. 0x742d35Cc... → vitalik.eth кардинально меняет читаемость.
Декодирование event logs
Логи транзакций часто не менее важны, чем calldata:
import { decodeEventLog } from "viem"
async function decodeTransactionLogs(txHash: `0x${string}`) {
const receipt = await client.getTransactionReceipt({ hash: txHash })
const decodedLogs = await Promise.allSettled(
receipt.logs.map(async (log) => {
const abi = await fetchContractABI(log.address)
if (!abi) return { raw: log, decoded: null }
try {
const decoded = decodeEventLog({ abi, data: log.data, topics: log.topics })
return { raw: log, decoded }
} catch {
return { raw: log, decoded: null }
}
})
)
return decodedLogs
.filter(r => r.status === "fulfilled")
.map(r => (r as PromiseFulfilledResult<any>).value)
}
Визуализация цепочки вызовов
Для сложных транзакций (aggregator routes, batch calls) нужно visualize call trace. Alchemy и Tenderly предоставляют API для получения трейсов:
// Tenderly simulation + trace
const trace = await fetch("https://api.tenderly.co/api/v1/simulate", {
method: "POST",
headers: { "X-Access-Key": TENDERLY_KEY },
body: JSON.stringify({
network_id: "1",
from: senderAddress,
to: contractAddress,
input: calldata,
gas: 500000,
save: false
})
})
Результат — дерево вызовов с decoded функциями на каждом уровне. Используем для отображения multi-hop свапов, flash loan операций.
Компонент декодировщика
function TransactionDecoder({ txData, contractAddress }: {
txData: `0x${string}`
contractAddress: `0x${string}`
}) {
const { data, isLoading } = useDecodeTransaction(txData, contractAddress)
if (isLoading) return <Skeleton />
if (!data) return (
<div className="font-mono text-sm text-muted">
Unknown function: {txData.slice(0, 10)}
</div>
)
return (
<div className="space-y-2">
<div className="font-semibold">{data.functionName}</div>
{data.params.map(param => (
<div key={param.name} className="flex gap-2 text-sm">
<span className="text-muted">{param.name}:</span>
<span className="font-mono">{param.value}</span>
</div>
))}
</div>
)
}
Сроки разработки
День 1: Ядро декодирования — viem decodeFunctionData, интеграция с 4byte.directory, Etherscan ABI fetcher с кешированием.
День 2: Proxy resolution, ENS lookup, human-readable форматирование параметров.
День 3: Декодирование event logs, базовый React компонент с отображением.
День 4-5: Call trace через Tenderly/Alchemy, отображение мультишаговых транзакций, Edge cases (multicall, batch операции).
Базовый декодировщик calldata + logs — 3 дня. Полноценный инструмент с трейсами и rich форматированием — 4-5 дней.







