From c4b16f1205bee7d5c83c23d1918b93a499175047 Mon Sep 17 00:00:00 2001 From: Patricio Tourne Passarino Date: Tue, 21 Jul 2026 09:33:02 -0300 Subject: [PATCH 1/3] feat: use generated wrappers for TS debugging --- contracts/tests/utils/opcodeRegistry.ts | 68 +++++++++++++++++++++++++ contracts/tests/utils/prettyPrint.ts | 40 ++++++++++++++- 2 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 contracts/tests/utils/opcodeRegistry.ts diff --git a/contracts/tests/utils/opcodeRegistry.ts b/contracts/tests/utils/opcodeRegistry.ts new file mode 100644 index 000000000..5edca679e --- /dev/null +++ b/contracts/tests/utils/opcodeRegistry.ts @@ -0,0 +1,68 @@ +import * as fs from 'fs' +import * as path from 'path' +import { Slice } from '@ton/core' + +const GEN_DIR = path.resolve(__dirname, '../../wrappers/gen') + +export interface OpcodeEntry { + name: string + fromSlice: (s: Slice) => Record & { readonly $: string } +} + +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 + +/** + * 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 + registry = 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 (isOpcodeStruct(value)) { + const entries = registry.get(value.PREFIX) ?? [] + entries.push({ name: exportName, fromSlice: value.fromSlice }) + registry.set(value.PREFIX, entries) + } + } + } + return registry +} diff --git a/contracts/tests/utils/prettyPrint.ts b/contracts/tests/utils/prettyPrint.ts index 3c426282f..335f02a93 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 } from './opcodeRegistry' /** * Exit code type - represents TVM exit codes @@ -139,8 +140,36 @@ 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`). + */ +function formatFieldValue(value: unknown): string { + if (typeof value === 'bigint') return value.toString() + 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 (value !== null && typeof value === 'object' && '$' in value) { + return formatStruct(value as Record & { $: string }) + } + return String(value) +} + +function formatStruct(struct: Record & { $: string }): string { + const fields = Object.entries(struct) + .filter(([key]) => key !== '$') + .map(([key, value]) => `${key}: ${formatFieldValue(value)}`) + .join(', ') + return `${struct.$}{${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 { @@ -153,7 +182,16 @@ function describeBody(body: Cell): string { // Try to parse as opcode (first 32 bits) if (slice.remainingBits >= 32) { try { - const opcode = slice.loadUint(32) + 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. + } + } return `opcode: 0x${opcode.toString(16).padStart(8, '0')}` } catch { // Fall through to string parsing From 33b797bf8e0bd6e2aaf79cfe0f813984f1e284fe Mon Sep 17 00:00:00 2001 From: Patricio Tourne Passarino Date: Mon, 3 Aug 2026 14:22:31 -0300 Subject: [PATCH 2/3] fixes: add struct name if fails to parse + missing register custom builder --- contracts/tests/utils/opcodeRegistry.ts | 45 +++++++++++++++++++++++-- contracts/tests/utils/prettyPrint.ts | 22 +++++++----- contracts/wrappers/gen/index.ts | 37 +++++++++++++------- 3 files changed, 81 insertions(+), 23 deletions(-) diff --git a/contracts/tests/utils/opcodeRegistry.ts b/contracts/tests/utils/opcodeRegistry.ts index 5edca679e..fadc85188 100644 --- a/contracts/tests/utils/opcodeRegistry.ts +++ b/contracts/tests/utils/opcodeRegistry.ts @@ -1,6 +1,7 @@ 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') @@ -9,6 +10,14 @@ export interface OpcodeEntry { 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 })) { @@ -35,6 +44,7 @@ function isOpcodeStruct(value: unknown): value is { } let registry: Map | undefined +let names: Map> | undefined /** * Builds a map of 32-bit opcode prefix -> candidate decoders, by scanning every generated @@ -48,7 +58,18 @@ let registry: Map | undefined */ export function getOpcodeRegistry(): Map { if (registry) return registry - registry = new Map() + 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 { @@ -57,12 +78,30 @@ export function getOpcodeRegistry(): Map { 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 = registry.get(value.PREFIX) ?? [] + const entries = localRegistry.get(value.PREFIX) ?? [] entries.push({ name: exportName, fromSlice: value.fromSlice }) - registry.set(value.PREFIX, entries) + 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 335f02a93..123b3f624 100644 --- a/contracts/tests/utils/prettyPrint.ts +++ b/contracts/tests/utils/prettyPrint.ts @@ -8,7 +8,7 @@ import { } from '@ton/core' import { BlockchainTransaction } from '@ton/sandbox' import { prettifyTransaction, PrettyTransaction } from '@ton/test-utils' -import { getOpcodeRegistry } from './opcodeRegistry' +import { getOpcodeRegistry, getOpcodeNames } from './opcodeRegistry' /** * Exit code type - represents TVM exit codes @@ -141,27 +141,31 @@ function describeExitCode(exitCode?: ExitCode): string { } /** - * Formats a decoded struct field value for display, recursing into nested - * structs (values carrying a `$` discriminant, as produced by generated `fromSlice`). + * 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 (value !== null && typeof value === 'object' && '$' in value) { - return formatStruct(value as Record & { $: string }) + if (typeof value === 'object' && value.constructor === Object) { + return formatStruct(value as Record) } return String(value) } -function formatStruct(struct: Record & { $: string }): string { +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 `${struct.$}{${fields}}` + return `${name}{${fields}}` } /** @@ -192,7 +196,9 @@ function describeBody(body: Cell): string { // pack/unpack registration in this contract file); try the next candidate. } } - return `opcode: 0x${opcode.toString(16).padStart(8, '0')}` + 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 } diff --git a/contracts/wrappers/gen/index.ts b/contracts/wrappers/gen/index.ts index 766fa889f..5973c5d5e 100644 --- a/contracts/wrappers/gen/index.ts +++ b/contracts/wrappers/gen/index.ts @@ -6,7 +6,7 @@ import { OnRamp } from './ccip/OnRamp'; import { FeeQuoter } from './ccip/FeeQuoter'; import { ReceiveExecutor } from './ccip/ReceiveExecutor'; import { CCIPSendExecutor } from './ccip/CCIPSendExecutor'; - +import { MerkleRoot } from './ccip/MerkleRoot'; import { TokenPool } from './ccip/pools/TokenPool' import { BurnMintTokenPool } from './ccip/pools/BurnMintTokenPool' import { LockReleaseTokenPool } from './ccip/pools/LockReleaseTokenPool' @@ -20,61 +20,66 @@ import * as CrossChainAddressCodec from '../ccip/common/CrossChainAddressCodec' export function setupGenBindings() { // Setup custom pack/unpack for CrossChainAddress - TokenPool.registerCustomPackUnpack( + + /* CCIP Contracts */ + + CCIPSendExecutor.registerCustomPackUnpack( 'CrossChainAddress', CrossChainAddressCodec.packToBuilder, CrossChainAddressCodec.unpackFromSlice, ) - BurnMintTokenPool.registerCustomPackUnpack( + ReceiveExecutor.registerCustomPackUnpack( 'CrossChainAddress', CrossChainAddressCodec.packToBuilder, CrossChainAddressCodec.unpackFromSlice, ) - LockReleaseTokenPool.registerCustomPackUnpack( + OffRamp.registerCustomPackUnpack( 'CrossChainAddress', CrossChainAddressCodec.packToBuilder, CrossChainAddressCodec.unpackFromSlice, ) - Router.registerCustomPackUnpack( + OnRamp.registerCustomPackUnpack( 'CrossChainAddress', CrossChainAddressCodec.packToBuilder, CrossChainAddressCodec.unpackFromSlice, ) - CCIPSendExecutor.registerCustomPackUnpack( + FeeQuoter.registerCustomPackUnpack( 'CrossChainAddress', CrossChainAddressCodec.packToBuilder, CrossChainAddressCodec.unpackFromSlice, ) - - ReceiveExecutor.registerCustomPackUnpack( + + Router.registerCustomPackUnpack( 'CrossChainAddress', CrossChainAddressCodec.packToBuilder, CrossChainAddressCodec.unpackFromSlice, ) - OffRamp.registerCustomPackUnpack( + MerkleRoot.registerCustomPackUnpack( 'CrossChainAddress', CrossChainAddressCodec.packToBuilder, CrossChainAddressCodec.unpackFromSlice, ) - OnRamp.registerCustomPackUnpack( + /* Token Pools */ + + TokenPool.registerCustomPackUnpack( 'CrossChainAddress', CrossChainAddressCodec.packToBuilder, CrossChainAddressCodec.unpackFromSlice, ) - FeeQuoter.registerCustomPackUnpack( + BurnMintTokenPool.registerCustomPackUnpack( 'CrossChainAddress', CrossChainAddressCodec.packToBuilder, CrossChainAddressCodec.unpackFromSlice, ) - MockTokenPool.registerCustomPackUnpack( + LockReleaseTokenPool.registerCustomPackUnpack( 'CrossChainAddress', CrossChainAddressCodec.packToBuilder, CrossChainAddressCodec.unpackFromSlice, @@ -85,6 +90,14 @@ export function setupGenBindings() { CrossChainAddressCodec.packToBuilder, CrossChainAddressCodec.unpackFromSlice, ) + + MockTokenPool.registerCustomPackUnpack( + 'CrossChainAddress', + CrossChainAddressCodec.packToBuilder, + CrossChainAddressCodec.unpackFromSlice, + ) + + /* Test Contracts */ TestMsgHasher.registerCustomPackUnpack( 'CrossChainAddress', From 18850b026cb1b299d9d4aa4a7b68ad3d9dea6762 Mon Sep 17 00:00:00 2001 From: Patricio Tourne Passarino Date: Mon, 3 Aug 2026 14:27:56 -0300 Subject: [PATCH 3/3] ref: clean setupGenBindings --- contracts/wrappers/gen/index.ts | 119 +++++++++----------------------- 1 file changed, 34 insertions(+), 85 deletions(-) diff --git a/contracts/wrappers/gen/index.ts b/contracts/wrappers/gen/index.ts index 5973c5d5e..a639eb655 100644 --- a/contracts/wrappers/gen/index.ts +++ b/contracts/wrappers/gen/index.ts @@ -18,92 +18,41 @@ import { TestMsgHasher } from './test/TestMsgHasher' import * as CrossChainAddressCodec from '../ccip/common/CrossChainAddressCodec' +// Setup custom pack/unpack for CrossChainAddress export function setupGenBindings() { - // Setup custom pack/unpack for CrossChainAddress - - /* CCIP Contracts */ - - CCIPSendExecutor.registerCustomPackUnpack( - 'CrossChainAddress', - CrossChainAddressCodec.packToBuilder, - CrossChainAddressCodec.unpackFromSlice, - ) - - ReceiveExecutor.registerCustomPackUnpack( - 'CrossChainAddress', - CrossChainAddressCodec.packToBuilder, - CrossChainAddressCodec.unpackFromSlice, - ) - - OffRamp.registerCustomPackUnpack( - 'CrossChainAddress', - CrossChainAddressCodec.packToBuilder, - CrossChainAddressCodec.unpackFromSlice, - ) - - OnRamp.registerCustomPackUnpack( - 'CrossChainAddress', - CrossChainAddressCodec.packToBuilder, - CrossChainAddressCodec.unpackFromSlice, - ) - - FeeQuoter.registerCustomPackUnpack( - 'CrossChainAddress', - CrossChainAddressCodec.packToBuilder, - CrossChainAddressCodec.unpackFromSlice, - ) - - Router.registerCustomPackUnpack( - 'CrossChainAddress', - CrossChainAddressCodec.packToBuilder, - CrossChainAddressCodec.unpackFromSlice, - ) - - MerkleRoot.registerCustomPackUnpack( - 'CrossChainAddress', - CrossChainAddressCodec.packToBuilder, - CrossChainAddressCodec.unpackFromSlice, - ) - - /* Token Pools */ - - TokenPool.registerCustomPackUnpack( - 'CrossChainAddress', - CrossChainAddressCodec.packToBuilder, - CrossChainAddressCodec.unpackFromSlice, - ) - - BurnMintTokenPool.registerCustomPackUnpack( - 'CrossChainAddress', - CrossChainAddressCodec.packToBuilder, - CrossChainAddressCodec.unpackFromSlice, - ) - - LockReleaseTokenPool.registerCustomPackUnpack( - 'CrossChainAddress', - CrossChainAddressCodec.packToBuilder, - CrossChainAddressCodec.unpackFromSlice, - ) - - LockReleaseLockboxTokenPool.registerCustomPackUnpack( - 'CrossChainAddress', - CrossChainAddressCodec.packToBuilder, - CrossChainAddressCodec.unpackFromSlice, - ) - - MockTokenPool.registerCustomPackUnpack( - 'CrossChainAddress', - CrossChainAddressCodec.packToBuilder, - CrossChainAddressCodec.unpackFromSlice, - ) - - /* Test Contracts */ - - TestMsgHasher.registerCustomPackUnpack( - 'CrossChainAddress', - CrossChainAddressCodec.packToBuilder, - CrossChainAddressCodec.unpackFromSlice, - ) + const CCIPContracts = [ + CCIPSendExecutor, + ReceiveExecutor, + OffRamp, + OnRamp, + FeeQuoter, + Router, + MerkleRoot, + ] + + const TokenPools = [ + TokenPool, + BurnMintTokenPool, + LockReleaseTokenPool, + LockReleaseLockboxTokenPool, + MockTokenPool, + ] + + const TestContracts = [ + TestMsgHasher, + ] + + for (const wrapper of [ + ...CCIPContracts, + ...TokenPools, + ...TestContracts, + ]) { + wrapper.registerCustomPackUnpack( + 'CrossChainAddress', + CrossChainAddressCodec.packToBuilder, + CrossChainAddressCodec.unpackFromSlice, + ) + } } export interface CellCodec {