Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions contracts/tests/utils/opcodeRegistry.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> & { readonly $: string }
}

function hasPrefix(value: unknown): value is { PREFIX: number } {
return (
typeof value === 'object' &&
value !== null &&
typeof (value as Record<string, unknown>).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<string, unknown> & { readonly $: string }
} {
return (
typeof value === 'object' &&
value !== null &&
typeof (value as Record<string, unknown>).PREFIX === 'number' &&
typeof (value as Record<string, unknown>).fromSlice === 'function'
)
}

let registry: Map<number, OpcodeEntry[]> | undefined
let names: Map<number, Set<string>> | 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<number, OpcodeEntry[]> {
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<number, OpcodeEntry[]>()
let localNames = new Map<number, Set<string>>()
for (const file of collectGenFiles(GEN_DIR)) {
let mod: Record<string, unknown>
try {
mod = require(file) as Record<string, unknown>
} catch {
continue
}
for (const [exportName, value] of Object.entries(mod)) {
if (hasPrefix(value)) {
const nameSet = localNames.get(value.PREFIX) ?? new Set<string>()
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<T>`, 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) ?? [])
}
48 changes: 46 additions & 2 deletions contracts/tests/utils/prettyPrint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -139,8 +140,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<T> = { 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<string, unknown>)
}
return String(value)
}

function formatStruct(struct: Record<string, unknown>): 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 {
Expand All @@ -153,8 +186,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
}
Expand Down
108 changes: 35 additions & 73 deletions contracts/wrappers/gen/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -18,79 +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
TokenPool.registerCustomPackUnpack(
'CrossChainAddress',
CrossChainAddressCodec.packToBuilder,
CrossChainAddressCodec.unpackFromSlice,
)

BurnMintTokenPool.registerCustomPackUnpack(
'CrossChainAddress',
CrossChainAddressCodec.packToBuilder,
CrossChainAddressCodec.unpackFromSlice,
)

LockReleaseTokenPool.registerCustomPackUnpack(
'CrossChainAddress',
CrossChainAddressCodec.packToBuilder,
CrossChainAddressCodec.unpackFromSlice,
)

Router.registerCustomPackUnpack(
'CrossChainAddress',
CrossChainAddressCodec.packToBuilder,
CrossChainAddressCodec.unpackFromSlice,
)

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,
)

MockTokenPool.registerCustomPackUnpack(
'CrossChainAddress',
CrossChainAddressCodec.packToBuilder,
CrossChainAddressCodec.unpackFromSlice,
)

LockReleaseLockboxTokenPool.registerCustomPackUnpack(
'CrossChainAddress',
CrossChainAddressCodec.packToBuilder,
CrossChainAddressCodec.unpackFromSlice,
)

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<T> {
Expand Down
Loading