import { Buffer } from 'buffer' import { type Transaction, Address, Cell, beginCell, fromNano, toNano } from '@ton/core' import { TonClient } from '@ton/ton' import { type AxiosAdapter, getAdapter } from 'axios' import { type BytesLike, hexlify, isBytesLike, isHexString, toBeArray, toBeHex } from 'ethers' import { type Memoized, memoize } from 'micro-memoize' import type { PickDeep } from 'type-fest' import { streamTransactionsForAddress } from './logs.ts' import { generateUnsignedCcipSend, getFee as getFeeImpl } from './send.ts' import { type ChainContext, type ChainStatic, type GetBalanceOpts, type LogFilter, type TokenTransferFeeOpts, Chain, } from '../chain.ts' import { CCIPArgumentInvalidError, CCIPExecutionReportChainMismatchError, CCIPHttpError, CCIPNotImplementedError, CCIPReceiptNotFoundError, CCIPSourceChainUnsupportedError, CCIPTopicsInvalidError, CCIPTransactionNotFoundError, CCIPWalletInvalidError, } from '../errors/index.ts' import type { EVMExtraArgsV2, ExtraArgs, SVMExtraArgsV1, SuiExtraArgsV1 } from '../extra-args.ts' import type { LeafHasher } from '../hasher/common.ts' import { buildMessageForDest, getMessagesInBatch } from '../requests.ts' import { supportedChains } from '../supported-chains.ts' import { type AnyMessage, type CCIPExecution, type CCIPMessage, type CCIPRequest, type ChainLog, type ChainTransaction, type CommitReport, type ExecutionInput, type ExecutionReceipt, type Lane, type NetworkInfo, type WithLogger, CCIPVersion, ChainFamily, ExecutionState, } from '../types.ts' import { bytesToBuffer, createRateLimitedFetch, decodeAddress, networkInfo, parseTypeAndVersion, } from '../utils.ts' import { generateUnsignedExecuteReport } from './exec.ts' import { decodeLegacyEVMTONExtraArgs, decodeTONExtraArgsCell, encodeExtraArgsCell, } from './extra-args.ts' import { getTONLeafHasher } from './hasher.ts' import { type UnsignedTONTx, isTONWallet } from './types.ts' import { crc32, lookupTxByRawHash, parseJettonContent } from './utils.ts' import type { CCIPMessage_V1_6_EVM } from '../evm/messages.ts' export type { TONWallet, UnsignedTONTx } from './types.ts' /** * Type guard to check if an error is a TVM error with an exit code. * TON VM errors include an exitCode property indicating the error type. */ function isTvmError(error: unknown): error is Error & { exitCode: number } { return error instanceof Error && 'exitCode' in error && typeof error.exitCode === 'number' } /** * TON chain implementation supporting TON networks. * * TON uses two different ordering concepts: * - `seqno` (sequence number): The actual block number in the blockchain * - `lt` (logical time): A per-account transaction ordering timestamp * * This implementation uses `lt` for the `blockNumber` field in logs and transactions * because TON's transaction APIs are indexed by `lt`, not `seqno`. The `lt` is * monotonically increasing per account and suitable for pagination and ordering. */ export class TONChain extends Chain { static { supportedChains[ChainFamily.TON] = TONChain } static readonly family = ChainFamily.TON static readonly decimals = 9 // TON uses 9 decimals (nanotons) static readonly extraArgGasLimitMin = toNano('0.025') // 0.025 TON readonly rateLimitedFetch: typeof fetch readonly provider: TonClient /** * Creates a new TONChain instance. * @param client - TonClient instance. * @param network - Network information for this chain. * @param ctx - Context containing logger. */ constructor( client: TonClient, network: NetworkInfo, ctx?: ChainContext & { fetchFn?: typeof fetch }, ) { super(network, ctx) this.provider = client const txCache = new Map() const txDepleted: Record = {} const origGetTransactions = this.provider.getTransactions.bind(this.provider) // cached getTransactions, used for getLogs this.provider.getTransactions = async ( address: Address, opts: Parameters[1], ): Promise => { const key = address.toString() let allTxs if (txCache.has(key)) { allTxs = txCache.get(key)! } else { allTxs = [] as Transaction[] txCache.set(key, allTxs) } let txs if (!opts.hash) { // if no cursor, always fetch most recent transactions txs = await origGetTransactions(address, opts) } else { const hash = opts.hash // otherwise, look to see if we have it already cached let idx = allTxs.findIndex((tx) => tx.hash().toString('base64') === hash) if (idx >= 0 && !opts.inclusive) idx++ // skip first if not inclusive // if found, and we have more than requested limit in cache, or we'd previously reached bottom of address if (idx >= 0 && (allTxs.length - idx >= opts.limit || txDepleted[key])) { return allTxs.slice(idx, idx + opts.limit) // return cached } // otherwise, fetch after end txs = await origGetTransactions(address, opts) } // add/merge unique/new/unseen txs to allTxs const allTxsHashes = new Set(allTxs.map((tx) => tx.hash().toString('base64'))) allTxs.push(...txs.filter((tx) => !allTxsHashes.has(tx.hash().toString('base64')))) allTxs.sort((a, b) => Number(b.lt - a.lt)) // merge sorted inverse order if (txs.length < opts.limit) txDepleted[key] = true // bottom reached return txs } // Rate-limited fetch for TonCenter API (public tier: ~1 req/sec) this.rateLimitedFetch = ctx?.fetchFn ?? createRateLimitedFetch({ maxRequests: 1, windowMs: 1500, maxRetries: 5 }, ctx) this.getTransaction = memoize(this.getTransaction.bind(this), { maxSize: 100, }) this.getBlockTimestamp = memoize(this.getBlockTimestamp.bind(this), { async: true, maxArgs: 1, maxSize: 100, forceUpdate: ([k]) => typeof k !== 'number' || k <= 0, }) this.typeAndVersion = memoize(this.typeAndVersion.bind(this), { maxArgs: 1, async: true, }) } /** * Detect client network and instantiate a TONChain instance. * @param client - TonClient instance connected to the TON network. * @param ctx - Optional chain context with logger, API client, and fetch function. * @returns TONChain instance configured for the detected network (mainnet or testnet). */ static async fromClient( client: TonClient, ctx?: ChainContext & { fetchFn?: typeof fetch }, ): Promise { // Verify connection by getting the latest block const isMainnet = ( await client.getContractState( Address.parse('EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs'), // mainnet USDT ) ).state === 'active' return new TONChain(client, networkInfo(isMainnet ? 'ton-mainnet' : 'ton-testnet'), ctx) } /** * Creates a TONChain instance from an RPC URL. * Verifies the connection and detects the network. * * @param url - RPC endpoint URL for TonClient (v2). * @param ctx - Context containing logger. * @returns A new TONChain instance. * @throws {@link CCIPHttpError} if connection to the RPC endpoint fails */ static async fromUrl(url: string, ctx?: ChainContext): Promise { const { logger = console } = ctx ?? {} if (!url.endsWith('/jsonRPC')) url += '/jsonRPC' let fetchFn: typeof fetch | undefined let httpAdapter: AxiosAdapter | undefined if (['toncenter.com', 'tonapi.io'].some((d) => url.includes(d))) { logger.warn( 'Public TONCenter API calls are rate-limited to ~1 req/sec, some commands may be slow', ) fetchFn = createRateLimitedFetch({ maxRequests: 1, windowMs: 1500, maxRetries: 5 }, ctx) httpAdapter = (getAdapter as (name: string, config: object) => AxiosAdapter)('fetch', { env: { fetch: fetchFn }, }) } // Wrap the adapter (or the default 'http' adapter) so that every TonClient axios // request inherits the abort signal. Without this, raceAc.abort() fires and prints // "Aborting RPC race" but the in-flight axios socket has no signal to cancel against // and stays alive in the keep-alive pool, preventing natural process exit. if (ctx?.abort) { const abort = ctx.abort const base = httpAdapter ?? (getAdapter as (name: string) => AxiosAdapter)('http') httpAdapter = (config) => base({ ...config, signal: config.signal ? AbortSignal.any([config.signal as AbortSignal, abort]) : abort, }) } const client = new TonClient({ endpoint: url, httpAdapter }) try { const chain = await this.fromClient(client, { ...ctx, fetchFn, }) logger.debug(`Connected to TON V2 endpoint: ${url}`) return chain } catch (error) { const message = error instanceof Error ? error.message : String(error) throw new CCIPHttpError(0, `Failed to connect to TONv2 endpoint ${url}: ${message}`) } } /** * Fetch the timestamp for a given logical time (lt) or finalized block. * * Note: For TON, the `block` parameter represents logical time (lt), not block seqno. * This is because TON transaction APIs are indexed by lt. The lt must have been * previously cached via getLogs or getTransaction calls. * * @param block - Logical time (lt) as number, or 'finalized' for latest block timestamp * @returns Unix timestamp in seconds * @throws {@link CCIPNotImplementedError} if lt is not in cache */ async getBlockTimestamp(block: number | 'finalized'): Promise { if (typeof block != 'number') { return Promise.resolve(Math.floor(Date.now() / 1000)) } // For TON, we cannot look up timestamp by lt alone without the account address. // The lt must have been cached during a previous getLogs or getTransaction call. throw new CCIPNotImplementedError( `getBlockTimestamp: lt ${block} not in cache. ` + `TON requires lt to be cached from getLogs or getTransaction calls first.`, ) } /** * Fetches a transaction by its hash. * * Supports two formats: * 1. Composite format: "workchain:address:lt:hash" (e.g., "0:abc123...def:12345:abc123...def") * 2. Raw hash format: 64-character hex string resolved via TonCenter V3 API * * Note: TonClient requires (address, lt, hash) for lookups. Raw hash lookups * use TonCenter's V3 index API to resolve the hash to a full identifier first. * * @param tx - Transaction identifier in either format * @returns ChainTransaction with transaction details * Note: `blockNumber` contains logical time (lt), not block seqno * @throws {@link CCIPArgumentInvalidError} if hash format is invalid * @throws {@link CCIPTransactionNotFoundError} if transaction not found */ async getTransaction(tx: string | Transaction): Promise { let address if (typeof tx === 'string') { let parts = tx.split(':') // If not composite format (4 parts), check if it's a raw 64-char hex hash if (parts.length !== 4) { const cleanHash = tx.startsWith('0x') || tx.startsWith('0X') ? tx.slice(2) : tx if (!/^[a-fA-F0-9]{64}$/.test(cleanHash)) throw new CCIPArgumentInvalidError( 'hash', `Invalid TON transaction hash format: "${tx}". Expected "workchain:address:lt:hash" or 64-char hex hash`, ) const txInfo = await lookupTxByRawHash( cleanHash, this.network.networkType, this.rateLimitedFetch, this, ) tx = `${txInfo.account}:${txInfo.lt}:${cleanHash}` this.logger.debug(`Resolved raw hash to composite: ${tx}`) parts = tx.split(':') } // Parse composite format: workchain:address:lt:hash address = Address.parseRaw(`${parts[0]}:${parts[1]}`) const [, , lt, txHash] = parts as [string, string, string, string] // Fetch transactions and find the one we're looking for const tx_ = await this.provider.getTransaction( address, lt, Buffer.from(txHash, 'hex').toString('base64'), ) if (!tx_) throw new CCIPTransactionNotFoundError(tx) tx = tx_ } else { address = new Address(0, Buffer.from(toBeArray(tx.address, 32))) } // Cache lt → timestamp for later getBlockTimestamp lookups ;(this.getBlockTimestamp as Memoized).cache.set( [Number(tx.lt)], Promise.resolve(tx.now), ) // Extract logs from outgoing external messages // Build composite hash format: workchain:address:lt:hash const compositeHash = `${address.toRawString()}:${tx.lt}:${tx.hash().toString('hex')}` const res = { hash: compositeHash, logs: [] as ChainLog[], blockNumber: Number(tx.lt), // Note: This is lt (logical time), not block seqno timestamp: tx.now, from: address.toRawString(), tx, } const logs: ChainLog[] = [] for (const [index, msg] of tx.outMessages) { if (msg.info.type !== 'external-out') continue const topics = [] // logs are external messages where dest "address" is the uint32 topic (e.g. crc32("ExecutionStateChanged")) if ( msg.info.dest && msg.info.dest.value > 0n && msg.info.dest.value < BigInt(2) ** BigInt(32) ) topics.push(toBeHex(msg.info.dest.value, 4)) let data = '' try { data = msg.body.toBoc().toString('base64') } catch (_) { // ignore } logs.push({ address: msg.info.src.toRawString(), topics, data, blockNumber: res.blockNumber, // Note: This is lt (logical time), not block seqno transactionHash: res.hash, index, tx: res, }) } res.logs = logs return res } /** * Async generator that yields logs from TON transactions. * * Note: For TON, `startBlock` and `endBlock` in opts represent logical time (lt), * not block sequence numbers. This is because TON transaction APIs are indexed by lt. * * @param opts - Log filter options (startBlock/endBlock are interpreted as lt values) * @throws {@link CCIPTopicsInvalidError} if topics format is invalid */ async *getLogs(opts: LogFilter): AsyncIterableIterator { if (opts.watch) { opts = { ...opts, watch: opts.watch instanceof AbortSignal ? AbortSignal.any([opts.watch, this.abort]) : this.abort, } } let topics if (opts.topics?.length) { if (!opts.topics.every((topic) => typeof topic === 'string')) throw new CCIPTopicsInvalidError(opts.topics) // append events discriminants (if not 0x-8B already), but keep OG topics topics = new Set([ ...opts.topics, ...opts.topics.filter((t) => !isHexString(t, 8)).map((t) => crc32(t)), ]) } for await (const tx of streamTransactionsForAddress(opts, this)) { for (const log of tx.logs) { if (topics && !topics.has(log.topics[0]!)) continue yield log } } } /** {@inheritDoc Chain.getMessagesInBatch} */ override async getMessagesInBatch< R extends PickDeep< CCIPRequest, 'lane' | `log.${'topics' | 'address' | 'blockNumber'}` | 'message.sequenceNumber' >, >( request: R, range: Pick, opts?: Pick, ): Promise { return getMessagesInBatch(this, request, range, opts) } /** {@inheritDoc Chain.typeAndVersion} */ async typeAndVersion(address: string) { const tonAddress = Address.parse(address) // Call the typeAndVersion getter method on the contract const result = await this.provider.runMethod(tonAddress, 'typeAndVersion') // Parse the two string slices returned by the contract // TON contracts return strings as cells with snake format encoding const typeCell = result.stack.readCell() const versionCell = result.stack.readCell() // Load strings from cells using snake format const contractType = typeCell.beginParse().loadStringTail() const version = versionCell.beginParse().loadStringTail() // Extract just the last part of the type (e.g., "OffRamp" from "com.chainlink.ton.ccip.OffRamp") const typeParts = contractType.split('.') const shortType = typeParts[typeParts.length - 1] // Format as "Type Version" and use the common parser const typeAndVersionStr = `${shortType} ${version}` return parseTypeAndVersion(typeAndVersionStr) } /** {@inheritDoc Chain.getRouterForOnRamp} */ async getRouterForOnRamp(onRamp: string, destChainSelector: bigint): Promise { const { stack: destConfig } = await this.provider.runMethod( Address.parse(onRamp), 'destChainConfig', [{ type: 'int', value: destChainSelector }], ) return destConfig.readAddress().toRawString() } /** {@inheritDoc Chain.getRouterForOffRamp} */ async getRouterForOffRamp(offRamp: string, sourceChainSelector: bigint): Promise { const { stack } = await this.provider.runMethod(Address.parse(offRamp), 'sourceChainConfig', [ { type: 'int', value: sourceChainSelector }, ]) return stack.readAddress().toRawString() } /** * {@inheritDoc Chain.getNativeTokenForRouter} * @throws {@link CCIPNotImplementedError} always (not implemented for TON) */ getNativeTokenForRouter(_router: string): Promise { // TON native token is represented as address 0:0...01 (workchain 0, hash = 1) // This is a convention for representing native TON in CCIP return Promise.resolve('0:0000000000000000000000000000000000000000000000000000000000000001') } /** {@inheritDoc Chain.getOffRampsForRouter} */ async getOffRampsForRouter(router: string, sourceChainSelector: bigint): Promise { const routerContract = this.provider.provider(Address.parse(router)) // Get the specific OffRamp for the source chain selector const { stack } = await routerContract.get('offRamp', [ { type: 'int', value: sourceChainSelector }, ]) return [stack.readAddress().toRawString()] } /** {@inheritDoc Chain.getOnRampForRouter} */ async getOnRampForRouter(router: string, destChainSelector: bigint): Promise { const routerContract = this.provider.provider(Address.parse(router)) // Get the specific OnRamp for the source chain selector const { stack } = await routerContract.get('onRamp', [ { type: 'int', value: destChainSelector }, ]) return stack.readAddress().toRawString() } /** * {@inheritDoc Chain.getOnRampsForOffRamp} * @throws {@link CCIPSourceChainUnsupportedError} if source chain is not configured */ async getOnRampsForOffRamp(offRamp: string, sourceChainSelector: bigint): Promise { try { const offRampContract = this.provider.provider(Address.parse(offRamp)) const { stack } = await offRampContract.get('sourceChainConfig', [ { type: 'int', value: sourceChainSelector }, ]) stack.readAddress() // router stack.readBoolean() // isEnabled stack.readBigNumber() // minSeqNr stack.readBoolean() // isRMNVerificationDisabled // onRamp is stored as CrossChainAddress cell const onRampCell = stack.readCell() const onRampSlice = onRampCell.beginParse() // Check if length-prefixed or raw format based on cell bit length const cellBits = onRampCell.bits.length let onRamp: Buffer if (cellBits === 160 || cellBits === 256) { // Raw 20-byte EVM address (no length prefix) onRamp = onRampSlice.loadBuffer(cellBits / 8) } else { // Length-prefixed format: 8-bit length + data const onRampLength = onRampSlice.loadUint(8) onRamp = onRampSlice.loadBuffer(onRampLength) } return [decodeAddress(onRamp, networkInfo(sourceChainSelector).family)] } catch (error) { if (isTvmError(error) && error.exitCode === 266) { throw new CCIPSourceChainUnsupportedError(sourceChainSelector, { context: { offRamp }, }) } throw error } } /** * {@inheritDoc Chain.getTokenForTokenPool} * @throws {@link CCIPNotImplementedError} always (not implemented for TON) */ async getTokenForTokenPool(_tokenPool: string): Promise { return Promise.reject(new CCIPNotImplementedError('getTokenForTokenPool')) } /** {@inheritDoc Chain.getTokenInfo} */ async getTokenInfo(token: string): Promise<{ symbol: string; decimals: number }> { const tokenAddress = Address.parse(token) if (tokenAddress.toRawString().match(/^[0:]+1$/)) { return { symbol: 'TON', decimals: (this.constructor as typeof TONChain).decimals } } try { const { stack } = await this.provider.runMethod(tokenAddress, 'get_jetton_data') // skips stack.readBigNumber() // total_supply stack.readBigNumber() // mintable stack.readAddress() // admin_address const contentCell = stack.readCell() return parseJettonContent(contentCell, this.rateLimitedFetch, this.logger) } catch (error) { this.logger.debug(`Failed to get jetton data for ${token}:`, error) return { symbol: '', decimals: (this.constructor as typeof TONChain).decimals } } } /** {@inheritDoc Chain.getBalance} */ async getBalance(opts: GetBalanceOpts): Promise { const { holder, token } = opts const holderAddress = Address.parse(holder) if (!token) { // Get native TON balance const state = await this.provider.getContractState(holderAddress) return state.balance } // For jetton balance, we need to: // 1. Derive the jetton wallet address for this holder // 2. Query the balance from that wallet contract const jettonMaster = Address.parse(token) const { stack } = await this.provider.runMethod(jettonMaster, 'get_wallet_address', [ { type: 'slice', cell: beginCell().storeAddress(holderAddress).endCell() }, ]) const jettonWalletAddress = stack.readAddress() try { const { stack: balanceStack } = await this.provider.runMethod( jettonWalletAddress, 'get_wallet_data', ) return balanceStack.readBigNumber() // First value is balance } catch { // Wallet doesn't exist yet = 0 balance return 0n } } /** * {@inheritDoc Chain.getTokenAdminRegistryFor} * @throws {@link CCIPNotImplementedError} always (not implemented for TON) */ getTokenAdminRegistryFor(_address: string): Promise { return Promise.reject(new CCIPNotImplementedError('getTokenAdminRegistryFor')) } /** * Decodes a CCIP message from a TON log event. * @param log - Log with data field. * @returns Decoded CCIPMessage, or undefined if the data is not a valid CCIP message (parse errors are caught and silently return undefined). */ static decodeMessage({ data, topics, }: { data: unknown topics?: readonly string[] }): CCIPMessage | undefined { if (!data || typeof data !== 'string') return if (topics?.length && topics[0] !== crc32('CCIPMessageSent')) return try { // Parse BOC from base64 const boc = bytesToBuffer(data) const cell = Cell.fromBoc(boc)[0]! const slice = cell.beginParse() // Load header fields directly (no topic prefix) // Structure from TVM2AnyRampMessage: // header: RampMessageHeader + sender: address + body: Cell + feeValueJuels: uint96 const header = { messageId: toBeHex(slice.loadUintBig(256), 32), sourceChainSelector: slice.loadUintBig(64), destChainSelector: slice.loadUintBig(64), sequenceNumber: slice.loadUintBig(64), nonce: slice.loadUintBig(64), } // Load sender address const sender = slice.loadAddress().toRawString() // Load body cell ref const bodyCell = slice.loadRef() // Load feeValueJuels (96 bits) at message level, after body ref const feeValueJuels = slice.loadUintBig(96) // Parse body cell: TVM2AnyRampMessageBody // Order: receiver (ref) + data (ref) + extraArgs (ref) + tokenAmounts (ref) + feeToken (inline) + feeTokenAmount (256 bits) const bodySlice = bodyCell.beginParse() // Load receiver from ref 0 (CrossChainAddress: length(8 bits) + bytes) const receiverSlice = bodySlice.loadRef().beginParse() const receiverLength = receiverSlice.loadUint(8) const receiverBytes = receiverSlice.loadBuffer(receiverLength) // Decode receiver address using destination chain's format let receiver: string try { const destFamily = networkInfo(header.destChainSelector).family receiver = decodeAddress(receiverBytes, destFamily) } catch { // Fallback to raw hex if chain not registered or decoding fails receiver = '0x' + receiverBytes.toString('hex') } // Load data from ref 1 const dataSlice = bodySlice.loadRef().beginParse() const dataBytes = dataSlice.loadBuffer(dataSlice.remainingBits / 8) // Load extraArgs from ref 2 const extraArgsCell = bodySlice.loadRef() // Serialize full cell graph so nested refs are preserved for SVM/Sui extraArgs. const extraArgs = hexlify(extraArgsCell.toBoc()) const parsed = this.decodeExtraArgs(extraArgs) if (!parsed) return const { _tag, ...extraArgsObj } = parsed // Load tokenAmounts from ref 3 const tokenAmounts: CCIPMessage_V1_6_EVM['tokenAmounts'] = [] // TODO: FIXME: parse when implemented // Load feeToken (inline address in body) const feeToken = bodySlice.loadMaybeAddress()?.toString() ?? '' // Load feeTokenAmount (256 bits) const feeTokenAmount = bodySlice.loadUintBig(256) return { ...header, sender, receiver, data: hexlify(dataBytes), tokenAmounts, feeToken, feeTokenAmount, feeValueJuels, extraArgs, ...extraArgsObj, } } catch { return undefined } } /** * Encodes TON extra args as a BOC-serialized cell. * * BOC serialization preserves nested refs, which is required for SVM and Sui * extra args that use snaked cells. * * @param args - Extra arguments containing gas limit and execution flags * @returns Hex string of BOC-encoded extra args (0x-prefixed) */ static encodeExtraArgs(args: ExtraArgs): string { const cell = encodeExtraArgsCell(args) return hexlify(cell.toBoc()) } /** * Decodes TON extra arguments. * Accepts BOC-serialized cells for all supported variants and legacy raw * GenericExtraArgsV2 bits for backward compatibility. * * @param extraArgs - Extra args as hex string or bytes * @returns Decoded TON extra args object or undefined if invalid */ static decodeExtraArgs( extraArgs: BytesLike, ): | (EVMExtraArgsV2 & { _tag: 'EVMExtraArgsV2' }) | (SVMExtraArgsV1 & { _tag: 'SVMExtraArgsV1' }) | (SuiExtraArgsV1 & { _tag: 'SuiExtraArgsV1' }) | undefined { try { const cell = Cell.fromBoc(bytesToBuffer(extraArgs))[0]! return decodeTONExtraArgsCell(cell) } catch { return decodeLegacyEVMTONExtraArgs(extraArgs) } } /** * Decodes commit reports from a TON log event (CommitReportAccepted). * * @param log - Log with data field (base64-encoded BOC). * @param lane - Optional lane info for filtering. * @returns Array of CommitReport or undefined if not a valid commit event. */ static decodeCommits( { data, topics }: { data: unknown; topics?: readonly string[] }, lane?: Lane, ): CommitReport[] | undefined { if (!data || typeof data !== 'string') return if (topics?.length && topics[0] !== crc32('CommitReportAccepted')) return try { const boc = bytesToBuffer(data) const cell = Cell.fromBoc(boc)[0]! const slice = cell.beginParse() // Cell body starts directly with hasMerkleRoot (topic is in message header) const hasMerkleRoot = slice.loadBit() // No merkle root: could be price-only update, skip for now if (!hasMerkleRoot) return // Read MerkleRoot fields inline const sourceChainSelector = slice.loadUintBig(64) const onRampLen = slice.loadUint(8) // Invalid onRamp length if (onRampLen === 0 || onRampLen > 32) return const onRampAddress = decodeAddress( slice.loadBuffer(onRampLen), networkInfo(sourceChainSelector).family, ) const minSeqNr = slice.loadUintBig(64) const maxSeqNr = slice.loadUintBig(64) const merkleRoot = hexlify(slice.loadBuffer(32)) as `0x${string}` // Read hasPriceUpdates (1 bit): we don't need the data but should consume it if (slice.remainingBits >= 1) { const hasPriceUpdates = slice.loadBit() if (hasPriceUpdates && slice.remainingRefs > 0) { slice.loadRef() // Skip price updates ref } } const report: CommitReport = { sourceChainSelector, onRampAddress, minSeqNr, maxSeqNr, merkleRoot, } // Filter by lane if provided if (lane) { if (report.sourceChainSelector !== lane.sourceChainSelector) return if (report.onRampAddress !== lane.onRamp) return } return [report] } catch { return } } /** * Decodes an execution receipt from a TON log event. * * The ExecutionStateChanged event structure (topic is in message header, not body): * - sourceChainSelector: uint64 (8 bytes) * - sequenceNumber: uint64 (8 bytes) * - messageId: uint256 (32 bytes) * - state: uint8 (1 byte) - InProgress=1, Success=2, Failed=3 * * @param log - Log with data field (base64-encoded BOC). * @returns ExecutionReceipt or undefined if not valid. */ static decodeReceipt({ data, topics, }: { data: unknown topics?: readonly string[] }): ExecutionReceipt | undefined { if (!data || typeof data !== 'string') return if (topics?.length && topics[0] !== crc32('ExecutionStateChanged')) return try { const boc = bytesToBuffer(data) const cell = Cell.fromBoc(boc)[0]! const slice = cell.beginParse() // ExecutionStateChanged has no refs if (cell.refs.length > 0) return // Cell body contains only the struct fields // ExecutionStateChanged: sourceChainSelector(64) + sequenceNumber(64) + messageId(256) + state(8) const sourceChainSelector = slice.loadUintBig(64) const sequenceNumber = slice.loadUintBig(64) const messageId = toBeHex(slice.loadUintBig(256), 32) const state = slice.loadUint(8) // Validate state is a valid ExecutionState (2-3) // TON has intermediary txs with state 1 (InProgress), but we filter them here if (state !== ExecutionState.Success && state !== ExecutionState.Failed) return return { messageId, sequenceNumber, sourceChainSelector, state: state as ExecutionState, } } catch { // ignore } } /** * Converts bytes to a TON address. * Handles: * - 36-byte CCIP format: workchain(4 bytes, big-endian) + hash(32 bytes) * - 33-byte format: workchain(1 byte) + hash(32 bytes) * - 32-byte format: hash only (assumes workchain 0) * Also handles user-friendly format strings (e.g., "EQ...", "UQ...", "kQ...", "0Q...") * and raw format strings ("workchain:hash"). * @param bytes - Bytes or string to convert. * @returns TON raw address string in format "workchain:hash". * @throws {@link CCIPArgumentInvalidError} if bytes length is invalid */ static getAddress(bytes: BytesLike): string { // If it's already a string address, try to parse and return raw format if (typeof bytes === 'string') { // Handle raw format "workchain:hash" if (bytes.includes(':') && !bytes.startsWith('0x')) { return bytes } // Handle user-friendly format (EQ..., UQ..., etc.) if ( bytes.startsWith('EQ') || bytes.startsWith('UQ') || bytes.startsWith('kQ') || bytes.startsWith('0Q') ) { return Address.parse(bytes).toRawString() } } const data = bytesToBuffer(bytes) if (data.length === 36) { // CCIP cross-chain format: workchain(4 bytes, big-endian) + hash(32 bytes) const workchain = data.readInt32BE(0) const hash = data.subarray(4).toString('hex') return `${workchain}:${hash}` } else if (data.length === 33) { // workchain (1 byte) + hash (32 bytes) const workchain = data[0] === 0xff ? -1 : data[0] const hash = data.subarray(1).toString('hex') return `${workchain}:${hash}` } else if (data.length === 32) { // hash only, assume workchain 0 return `0:${data.toString('hex')}` } else { throw new CCIPArgumentInvalidError( 'bytes', `Invalid TON address bytes length: ${data.length}. Expected 32, 33, or 36 bytes.`, ) } } /** * Formats a TON address for human-friendly display. * Converts raw format (workchain:hash) to user-friendly format (EQ..., UQ..., etc.) * @param address - Address in any recognized format * @returns User-friendly TON address string */ static formatAddress(address: string): string { try { // Parse the address (handles both raw and friendly formats) const parsed = Address.parse(address) // Return user-friendly format (bounceable by default) return parsed.toString() } catch { // If parsing fails, return original return address } } /** * Formats a TON transaction hash for human-friendly display. * Extracts the raw 64-char hash from composite format for cleaner display. * @param hash - Transaction hash in composite or raw format * @returns The raw 64-char hex hash for display */ static formatTxHash(hash: string): string { const parts = hash.split(':') if (parts.length === 4) { // Composite format: workchain:address:lt:hash - return just the hash part return parts[3]! } // Already raw format or unknown - return as-is return hash } /** * Validates a transaction hash format for TON. * Supports: * - Raw 64-char hex hash (with or without 0x prefix) * - Composite format: "workchain:address:lt:hash" */ static isTxHash(v: unknown): v is string { if (typeof v !== 'string') return false // Check for raw 64-char hex hash (with or without 0x prefix) const cleanHash = v.startsWith('0x') || v.startsWith('0X') ? v.slice(2) : v if (/^[a-fA-F0-9]{64}$/.test(cleanHash)) { return true } // Check for composite format: workchain:address:lt:hash const parts = v.split(':') if (parts.length === 4) { const [workchain, address, lt, hash] = parts as [string, string, string, string] // workchain should be a number (typically 0 or -1) if (!/^-?\d+$/.test(workchain)) return false // address should be 64-char hex if (!/^[a-fA-F0-9]{64}$/.test(address)) return false // lt should be a number if (!/^\d+$/.test(lt)) return false // hash should be 64-char hex if (!/^[a-fA-F0-9]{64}$/.test(hash)) return false return true } return false } /** * Returns a copy of a message, populating missing fields like `extraArgs` with defaults. * Ensures TON-bound messages satisfy the minimum destination gas requirement. * * @param message - AnyMessage (from source), containing at least `receiver` * @returns A message suitable for `sendMessage` to a TON destination chain * @throws {@link CCIPArgumentInvalidError} if extraArgs.gasLimit is below the TON minimum */ static override buildMessageForDest( message: Parameters[0], ): AnyMessage { const built = super.buildMessageForDest(message) const gasLimit = 'gasLimit' in built.extraArgs ? built.extraArgs.gasLimit : undefined if (!gasLimit || gasLimit < this.extraArgGasLimitMin) { throw new CCIPArgumentInvalidError( 'extraArgs.gasLimit', `(val=${gasLimit}) must be at least ${this.extraArgGasLimitMin} (${fromNano(this.extraArgGasLimitMin)} TON) for TON destinations`, ) } return built } /** * Gets the leaf hasher for TON destination chains. * @param lane - Lane configuration. * @param _ctx - Context containing logger. * @returns Leaf hasher function. */ static getDestLeafHasher(lane: Lane, _ctx?: WithLogger): LeafHasher { return getTONLeafHasher(lane) } /** {@inheritDoc Chain.getFee} */ async getFee({ router, destChainSelector, message, }: Parameters[0]): Promise { return getFeeImpl( this, router, destChainSelector, buildMessageForDest(message, networkInfo(destChainSelector).family), ) } /** {@inheritDoc Chain.generateUnsignedSendMessage} */ async generateUnsignedSendMessage({ router, destChainSelector, message, sender, }: Parameters[0]): Promise { // Convert MessageInput to AnyMessage with defaults const populatedMessage = buildMessageForDest(message, networkInfo(destChainSelector).family) // Calculate fee if not provided const fee = message.fee ?? (await this.getFee({ router, destChainSelector, message: populatedMessage, })) const unsigned = generateUnsignedCcipSend(this, sender, router, destChainSelector, { ...populatedMessage, fee, }) return { family: ChainFamily.TON, ...unsigned, } } /** {@inheritDoc Chain.sendMessage} */ async sendMessage({ router, destChainSelector, message, wallet, }: Parameters[0]): Promise { if (!isTONWallet(wallet)) { throw new CCIPWalletInvalidError(wallet) } const sender = await wallet.getAddress() // Generate unsigned transaction with fee calculation if needed const { family: _, ...unsigned } = await this.generateUnsignedSendMessage({ router, destChainSelector, message, sender, }) // Send transaction const startTime = Math.floor(Date.now() / 1000) const seqno = await wallet.sendTransaction(unsigned) this.logger.info('CCIP send transaction submitted, seqno:', seqno) // Wait for CCIPMessageSent event and extract the request // Query the OnRamp for the CCIPMessageSent event const onRamp = await this.getOnRampForRouter(router, destChainSelector) // Poll for the message in recent logs for await (const log of this.getLogs({ address: onRamp, topics: [crc32('CCIPMessageSent')], startTime, watch: AbortSignal.timeout(5 * 60e3 /* 5m timeout */), })) { const msg = TONChain.decodeMessage(log) if (!msg) continue // Found our message: construct and return the CCIPRequest const tx = log.tx ?? (await this.getTransaction(log.transactionHash)) return { lane: { sourceChainSelector: this.network.chainSelector, destChainSelector, onRamp, version: CCIPVersion.V1_6, }, message: msg, log, tx, } } throw new CCIPTransactionNotFoundError(seqno.toString()) } /** * {@inheritDoc Chain.generateUnsignedExecute} * @throws {@link CCIPExtraArgsInvalidError} if extra args are not EVMExtraArgsV2 format */ async generateUnsignedExecute( opts: Parameters[0], ): Promise { const resolved = await this.resolveExecuteOpts(opts) const { offRamp, input } = resolved const unsigned = generateUnsignedExecuteReport( offRamp, input as ExecutionInput, resolved, ) return Promise.resolve({ family: ChainFamily.TON, ...unsigned, }) } /** * {@inheritDoc Chain.execute} * @throws {@link CCIPWalletInvalidError} if wallet is not a valid TON wallet * @throws {@link CCIPReceiptNotFoundError} if execution receipt not found within timeout */ async execute(opts: Parameters[0]): Promise { const { wallet } = opts if (!isTONWallet(wallet)) throw new CCIPWalletInvalidError(wallet) const payer = await wallet.getAddress() const resolved = await this.resolveExecuteOpts(opts) const { offRamp } = resolved if (!('message' in resolved.input)) throw new CCIPExecutionReportChainMismatchError('TON') const message = resolved.input.message as CCIPMessage_V1_6_EVM const { family: _, ...unsigned } = await this.generateUnsignedExecute({ ...resolved, payer, }) const startTime = Math.floor(Date.now() / 1000) // Open wallet and send transaction using the unsigned data const seqno = await wallet.sendTransaction({ value: toNano('0.3'), ...unsigned, }) for await (const exec of this.getExecutionReceipts({ offRamp, messageId: message.messageId, sourceChainSelector: message.sourceChainSelector, startTime, watch: AbortSignal.timeout(10 * 60e3 /* 10m */), })) { return exec // break and return on first yield } throw new CCIPReceiptNotFoundError(seqno.toString()) } /** * Parses raw TON data into typed structures. * @param data - Raw data to parse. * @returns Parsed data or undefined. */ static parse(data: unknown) { if (isBytesLike(data)) { const parsedExtraArgs = this.decodeExtraArgs(data) if (parsedExtraArgs) return parsedExtraArgs } } /** * {@inheritDoc Chain.getSupportedTokens} * @throws {@link CCIPNotImplementedError} always (not implemented for TON) */ async getSupportedTokens(_address: string): Promise { return Promise.reject(new CCIPNotImplementedError('getSupportedTokens')) } /** * {@inheritDoc Chain.getRegistryTokenConfig} * @throws {@link CCIPNotImplementedError} always (not implemented for TON) */ async getRegistryTokenConfig(_address: string, _tokenName: string): Promise { return Promise.reject(new CCIPNotImplementedError('getRegistryTokenConfig')) } /** * {@inheritDoc Chain.getTokenPoolConfig} * @throws {@link CCIPNotImplementedError} always (not implemented for TON) */ async getTokenPoolConfig(_tokenPool: string, _feeOpts?: TokenTransferFeeOpts): Promise { return Promise.reject(new CCIPNotImplementedError('getTokenPoolConfig')) } /** * {@inheritDoc Chain.getTokenPoolRemotes} * @throws {@link CCIPNotImplementedError} always (not implemented for TON) */ async getTokenPoolRemotes(_tokenPool: string): Promise { return Promise.reject(new CCIPNotImplementedError('getTokenPoolRemotes')) } /** * {@inheritDoc Chain.getFeeTokens} * @throws {@link CCIPNotImplementedError} always (not implemented for TON) */ async getFeeTokens(_router: string): Promise { return Promise.reject(new CCIPNotImplementedError('getFeeTokens')) } }