diff --git a/contracts/tests/utils/opcodeRegistry.ts b/contracts/tests/utils/opcodeRegistry.ts new file mode 100644 index 000000000..fadc85188 --- /dev/null +++ b/contracts/tests/utils/opcodeRegistry.ts @@ -0,0 +1,107 @@ +import * as fs from 'fs' +import * as path from 'path' +import { Slice } from '@ton/core' +import { setupGenBindings } from '../../wrappers/gen' + +const GEN_DIR = path.resolve(__dirname, '../../wrappers/gen') + +export interface OpcodeEntry { + name: string + fromSlice: (s: Slice) => Record & { readonly $: string } +} + +function hasPrefix(value: unknown): value is { PREFIX: number } { + return ( + typeof value === 'object' && + value !== null && + typeof (value as Record).PREFIX === 'number' + ) +} + +function collectGenFiles(dir: string): string[] { + const files: string[] = [] + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) { + files.push(...collectGenFiles(full)) + } else if (entry.isFile() && entry.name.endsWith('.ts') && entry.name !== 'index.ts') { + files.push(full) + } + } + return files +} + +function isOpcodeStruct(value: unknown): value is { + PREFIX: number + fromSlice: (s: Slice) => Record & { readonly $: string } +} { + return ( + typeof value === 'object' && + value !== null && + typeof (value as Record).PREFIX === 'number' && + typeof (value as Record).fromSlice === 'function' + ) +} + +let registry: Map | undefined +let names: Map> | undefined + +/** + * Builds a map of 32-bit opcode prefix -> candidate decoders, by scanning every generated + * wrapper under `wrappers/gen` for structs with a `PREFIX` + `fromSlice`. This means new + * contracts or regenerated bindings are picked up automatically without touching this file. + * + * The same struct is often redeclared per-contract-file (e.g. `OnRamp_Send` appears in both + * `OnRamp.ts` and `Router.ts`), and only the copy living in its "owning" contract has any + * custom pack/unpack callbacks (e.g. for `CrossChainAddress`) registered against it. So every + * candidate for a given prefix is kept, and the caller tries each until one decodes cleanly. + */ +export function getOpcodeRegistry(): Map { + if (registry) return registry + try { + // Individual spec files call this themselves when they need it directly; here we call it + // defensively so decoding still works even for specs (like debugging via `dump()`) that + // never call it. Swallow "already registered" errors since a spec file may have called it + // first. + setupGenBindings() + } catch { + // already registered by the spec under test + } + + let localRegistry = new Map() + let localNames = new Map>() + for (const file of collectGenFiles(GEN_DIR)) { + let mod: Record + try { + mod = require(file) as Record + } catch { + continue + } + for (const [exportName, value] of Object.entries(mod)) { + if (hasPrefix(value)) { + const nameSet = localNames.get(value.PREFIX) ?? new Set() + nameSet.add(exportName) + localNames.set(value.PREFIX, nameSet) + } + if (isOpcodeStruct(value)) { + const entries = localRegistry.get(value.PREFIX) ?? [] + entries.push({ name: exportName, fromSlice: value.fromSlice }) + localRegistry.set(value.PREFIX, entries) + } + } + } + registry = localRegistry + names = localNames + return registry +} + +/** + * Names of every struct declaring a given opcode `PREFIX`, even ones with no `fromSlice` (e.g. + * generic structs like `FeeQuoter_GetValidatedFee`, whose `context: T` field depends on the + * call site and so can't be decoded generically). Used as a fallback label when nothing in + * `getOpcodeRegistry()` can actually decode the body. + */ +export function getOpcodeNames(opcode: number): string[] { + getOpcodeRegistry() + return Array.from(names?.get(opcode) ?? []) +} diff --git a/contracts/tests/utils/prettyPrint.ts b/contracts/tests/utils/prettyPrint.ts index 0b741e82f..bff8a0a00 100644 --- a/contracts/tests/utils/prettyPrint.ts +++ b/contracts/tests/utils/prettyPrint.ts @@ -8,6 +8,7 @@ import { } from '@ton/core' import { BlockchainTransaction } from '@ton/sandbox' import { prettifyTransaction, PrettyTransaction } from '@ton/test-utils' +import { getOpcodeRegistry, getOpcodeNames } from './opcodeRegistry' /** * Exit code type - represents TVM exit codes @@ -142,8 +143,40 @@ function describeExitCode(exitCode?: ExitCode): string { return `exit code: ${exitCode} (${description})` } +/** + * Formats a decoded struct field value for display, recursing into nested structs (values + * carrying a `$` discriminant, as produced by generated `fromSlice`) and plain-object wrappers + * like `CellRef = { ref: T }`. Anything else (e.g. a `@ton/core` `Slice`) falls back to its + * own `toString()`. + */ +function formatFieldValue(value: unknown): string { + if (typeof value === 'bigint') return value.toString() + if (value === null || value === undefined) return String(value) + if (value instanceof Address) return value.toString() + if (value instanceof Cell) return `${value.toBoc().toString('hex').substring(0, 16)}...` + if (Buffer.isBuffer(value)) return value.toString('hex') + if (Array.isArray(value)) return `[${value.map(formatFieldValue).join(', ')}]` + if (typeof value === 'object' && value.constructor === Object) { + return formatStruct(value as Record) + } + return String(value) +} + +function formatStruct(struct: Record): string { + const name = typeof struct.$ === 'string' ? struct.$ : '' + const fields = Object.entries(struct) + .filter(([key]) => key !== '$') + .map(([key, value]) => `${key}: ${formatFieldValue(value)}`) + .join(', ') + return `${name}{${fields}}` +} + /** * Describes the body/payload of a message cell. + * + * Uses the generated wrapper bindings under `wrappers/gen` (see opcodeRegistry.ts) to decode + * known opcodes into their struct name and fields. Falls back to a raw opcode/hex dump for + * anything not recognized. */ function describeBody(body: Cell): string { try { @@ -156,8 +189,19 @@ function describeBody(body: Cell): string { // Try to parse as opcode (first 32 bits) if (slice.remainingBits >= 32) { try { - const opcode = slice.loadUint(32) - return `opcode: 0x${opcode.toString(16).padStart(8, '0')}` + const opcode = slice.preloadUint(32) + for (const entry of getOpcodeRegistry().get(opcode) ?? []) { + try { + const parsed = entry.fromSlice(body.beginParse()) + return formatStruct(parsed) + } catch { + // This redeclaration of the struct couldn't decode it (e.g. missing custom + // pack/unpack registration in this contract file); try the next candidate. + } + } + const hex = `opcode: 0x${opcode.toString(16).padStart(8, '0')}` + const names = getOpcodeNames(opcode) + return names.length > 0 ? `${hex} (${names.join(' | ')})` : hex } catch { // Fall through to string parsing }