import { Buffer } from 'buffer' import { type Transaction, Address, Cell, beginCell, fromNano, toNano } from '@ton/core' import { TonClient } from '@ton/ton' import { type BytesLike, hexlify, isBytesLike, isHexString, toBeArray, toBeHex } from 'ethers' import { memoize } from 'micro-memoize' import { decodeLegacyEVMTONExtraArgs, decodeTONExtraArgsCell, encodeExtraArgsCell, } from './extra-args.ts' import { streamTransactionsForAddress } from './logs.ts' import { generateUnsignedCcipSend, getFee as getFeeImpl } from './send.ts' import { type BlockInfo, type ChainContext, type ChainStatic, type GetBalanceOpts, type LogFilter, type TokenTransferFeeOpts, Chain, } from '../chain.ts' import { type UnsignedTONTx, isTONWallet } from './types.ts' import { CCIPArgumentInvalidError, CCIPExecutionReportChainMismatchError, CCIPHttpError, CCIPNotImplementedError, CCIPReceiptNotFoundError, CCIPSourceChainUnsupportedError, CCIPTopicsInvalidError, CCIPTransactionNotFoundError, CCIPWalletInvalidError, } from '../errors/index.ts' import type { CCIPMessage_V1_6_EVM } from '../evm/messages.ts' import type { EVMExtraArgsV2, ExtraArgs, SVMExtraArgsV1, SuiExtraArgsV1 } from '../extra-args.ts' import { createAxiosFetchAdapter, fetchProfileForUrl } from '../fetch.ts' import type { LeafHasher } from '../hasher/common.ts' import { type NetworkInfo, ChainFamily, networkInfo } from '../networks.ts' import { buildMessageForDest } 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 LeanNumbers, type WithLogger, CCIPVersion, ExecutionState, } from '../types.ts' import { bytesToBuffer, createRateLimitedFetch, decodeAddress, parseTypeAndVersion, } from '../utils.ts' import { generateUnsignedExecuteReport } from './exec.ts' import { getTONLeafHasher } from './hasher.ts' import { crc32, lookupTxByRawHash, parseJettonContent } from './utils.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 masterchain * - `lt` (logical time): A per-account transaction ordering timestamp * * This implementation uses the masterchain `seqno` for the `blockNumber` field and * the message's `lt` for the `logIndex` field. The `startBlock`/`endBlock` filter * parameters accept masterchain seqnos and are converted to lt ranges internally. */ export class TONChain extends Chain { static { supportedChains[ChainFamily.TON] = TONChain } static readonly family = ChainFamily.TON static readonly decimals = 9 // GRAM uses 9 decimals (nanograms) static readonly extraArgGasLimitMin = toNano('0.025') // 0.025 GRAM 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) { 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 } // Use caller-supplied fetch verbatim; fall back to a rate-limited default. this.rateLimitedFetch = ctx?.fetch ?? createRateLimitedFetch({ seed: { limit: 1, windowMs: 1500 }, maxRetries: 6 }, ctx) this.getTransaction = memoize(this.getTransaction.bind(this), { async: true, maxSize: 100, }) this.getBlockInfo = memoize(this.getBlockInfo.bind(this), { async: true, maxArgs: 1, maxSize: 100, forceUpdate: ([k]) => (typeof k !== 'number' && typeof k !== 'bigint') || k <= 0, }) this.typeAndVersion = memoize(this.typeAndVersion.bind(this), { maxArgs: 1, async: true, }) this.getOnRampConfig = memoize(this.getOnRampConfig.bind(this), { async: true, maxArgs: 2, expires: 60e3, }) this.getOffRampConfig = memoize(this.getOffRampConfig.bind(this), { async: true, maxArgs: 2, expires: 60e3, }) this.getMCSeqNoByLt = memoize(this.getMCSeqNoByLt.bind(this), { async: true, maxArgs: 1, maxSize: 100, }) this.getMCBlockHeader = memoize(this.getMCBlockHeader.bind(this), { async: true, maxArgs: 1, maxSize: 100, }) } /** * 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): 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' // Resolve the fetch function: user-supplied verbatim, then rate-limited default. const fetchFn: typeof fetch = ctx?.fetch ?? createRateLimitedFetch(fetchProfileForUrl(url), ctx) // For known public providers, detect network from URL to avoid an API call during init // (free-tier endpoints are rate-limited and return transient 5xx errors). let isMainnetHint: boolean | undefined if (['toncenter.com', 'tonapi.io'].some((d) => url.includes(d))) { // testnet.toncenter.com / testnet.tonapi.io → testnet; bare domain → mainnet isMainnetHint = !url.includes('testnet.') } // Always use the fetch adapter so our fetch function is used for all requests. // Also merges ctx.abort into every request signal so raceAc.abort() cancels in-flight sockets. const httpAdapter = createAxiosFetchAdapter(fetchFn, ctx?.abort) const client = new TonClient({ endpoint: url, httpAdapter }) try { const chain = isMainnetHint !== undefined ? new TONChain(client, networkInfo(isMainnetHint ? 'ton-mainnet' : 'ton-testnet'), { ...ctx, fetch: fetchFn, }) : await this.fromClient(client, { ...ctx, fetch: 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}`) } } /** * Fetches the block seqno (number) for a given logical time (lt). * @internal */ async getMCSeqNoByLt(lt: number | bigint): Promise { const res = await this.rateLimitedFetch(this.provider.parameters.endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: Date.now(), method: 'lookupBlock', params: { workchain: -1, shard: '-9223372036854775808', lt: lt.toString(), }, }), }) if (!res.ok) { const text = await res.text() throw new CCIPHttpError(res.status, `Failed to lookupBlock by lt=${lt}: ${text}`) } const { result } = (await res.json()) as { result: { seqno: number } } return result.seqno } /** * Fetches the masterchain block header by seqno. * @internal */ async getMCBlockHeader( block: number | bigint, ): Promise<{ gen_utime: number; start_lt: string; end_lt: string; min_ref_mc_seqno: number }> { const res = await this.rateLimitedFetch(this.provider.parameters.endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: Date.now(), method: 'getBlockHeader', params: { workchain: -1, shard: '-9223372036854775808', seqno: Number(block), }, }), }) if (!res.ok) { throw new CCIPHttpError( res.status, `Failed to getBlockHeader by seqno=${block}: ${await res.text()}`, ) } const { result } = (await res.json()) as { result: Awaited> } return result } /** * Fetch the timestamp for a given masterchain block or the latest finalized block. * * @param block - Masterchain block seqno, or 'finalized'/'latest' for the latest block * @returns Unix timestamp in seconds */ async getBlockInfo(block: number | bigint | 'finalized' | 'latest'): Promise { if (typeof block !== 'number' && typeof block !== 'bigint') { const info = await this.provider.getMasterchainInfo() block = info.latestSeqno } const seqno = Number(block) const result = await this.getMCBlockHeader(seqno) return { number: seqno, timestamp: result.gen_utime } } /** * 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 * `blockNumber` is the masterchain seqno; `logIndex` is the message's lt * @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))) } // 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 seqno = await this.getMCSeqNoByLt(tx.lt) const res = { hash: compositeHash, logs: [] as ChainLog[], blockNumber: seqno, timestamp: tx.now, from: address.toRawString(), tx, } const logs: ChainLog[] = [] for (const [, 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, // masterchain seqno blockTimestamp: tx.now, transactionHash: res.hash, index: Number(msg.info.createdLt), tx: res, }) } res.logs = logs return res } /** * Async generator that yields logs from TON transactions. * * `startBlock`/`endBlock` are masterchain seqnos (public interface). Internally, * they are converted to lt ranges before being passed to `streamTransactionsForAddress`, * which uses lt for TON transaction API pagination. * * @param opts - Log filter options (startBlock/endBlock are masterchain seqnos) * @throws {@link CCIPTopicsInvalidError} if topics format is invalid */ async *getLogs(opts: LeanNumbers): 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)), ]) } // streamTransactionsForAddress expects LT for startBlock/endBlock, so convert MC seqno to LT range as needed const opts_ = { ...opts } if (opts.startBlock != null) { opts_.startBlock = BigInt((await this.getMCBlockHeader(opts.startBlock)).start_lt) } if ( (typeof opts.endBlock === 'number' || typeof opts.endBlock === 'bigint') && opts.endBlock > 0 ) { opts_.endBlock = BigInt((await this.getMCBlockHeader(opts.endBlock)).end_lt) } 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.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.getOnRampConfig} */ async getOnRampConfig(onRamp: string, destChainSelector: bigint) { const onRampAddress = Address.parse(onRamp) const [ { stack: staticStack }, { stack: dynamicStack }, { stack: destStack }, [, , typeAndVersion], ] = await Promise.all([ this.provider.runMethod(onRampAddress, 'staticConfig', []), this.provider.runMethod(onRampAddress, 'dynamicConfig', []), this.provider.runMethod(onRampAddress, 'destChainConfig', [ { type: 'int', value: destChainSelector }, ]), this.typeAndVersion(onRamp), ]) // staticConfig() -> chainSelector: uint64 const chainSelector = staticStack.readBigNumber() // dynamicConfig() -> feeQuoter, feeAggregator, allowlistAdmin, reserve const feeQuoter = dynamicStack.readAddress().toString() const feeAggregator = dynamicStack.readAddress().toString() const allowlistAdmin = dynamicStack.readAddress().toString() const reserve = dynamicStack.readBigNumber() // destChainConfig() -> router, sequenceNumber, allowlistEnabled, allowedSenders (dict cell) const router = destStack.readAddress().toString() const sequenceNumber = BigInt(destStack.readBigNumber().toString()) const allowlistEnabled = destStack.readBoolean() const feeQuoterAddress = Address.parse(feeQuoter) const [{ stack: fqStaticStack }, { stack: fqDestStack }] = await Promise.all([ this.provider.runMethod(feeQuoterAddress, 'staticConfig', []), this.provider.runMethod(feeQuoterAddress, 'destChainConfig', [ { type: 'int', value: destChainSelector }, ]), ]) // FeeQuoter staticConfig() -> maxFeeJuelsPerMsg: uint96, linkToken: address, tokenPriceStalenessThreshold: uint32 const maxFeeJuelsPerMsg = fqStaticStack.readBigNumber() const linkToken = fqStaticStack.readAddress().toString() const tokenPriceStalenessThreshold = fqStaticStack.readNumber() // FeeQuoter destChainConfig() -> 18 FeeQuoterDestChainConfig scalar fields, then usdPerUnitGas cell const destChainConfig = { isEnabled: fqDestStack.readBoolean(), maxNumberOfTokensPerMsg: fqDestStack.readNumber(), maxDataBytes: fqDestStack.readNumber(), maxPerMsgGasLimit: fqDestStack.readNumber(), destGasOverhead: fqDestStack.readNumber(), destGasPerPayloadByteBase: fqDestStack.readNumber(), destGasPerPayloadByteHigh: fqDestStack.readNumber(), destGasPerPayloadByteThreshold: fqDestStack.readNumber(), destDataAvailabilityOverheadGas: fqDestStack.readNumber(), destGasPerDataAvailabilityByte: fqDestStack.readNumber(), destDataAvailabilityMultiplierBps: fqDestStack.readNumber(), chainFamilySelector: fqDestStack.readNumber(), defaultTokenFeeUsdCents: fqDestStack.readNumber(), defaultTokenDestGasOverhead: fqDestStack.readNumber(), defaultTxGasLimit: fqDestStack.readNumber(), gasMultiplierWeiPerEth: fqDestStack.readBigNumber(), gasPriceStalenessThreshold: fqDestStack.readNumber(), networkFeeUsdCents: fqDestStack.readNumber(), } // usdPerUnitGas is a cell ref following the 18 scalar fields (GasPrice struct) const usdPerUnitGasCell = fqDestStack.readCell() const gasSlice = usdPerUnitGasCell.beginParse() const usdPerUnitGas = { executionGasPrice: gasSlice.loadUintBig(112), dataAvailabilityGasPrice: gasSlice.loadUintBig(112), timestamp: gasSlice.loadUintBig(64), } return { chainSelector, destChainSelector, feeQuoter, feeAggregator, allowlistAdmin, reserve, router, sequenceNumber, allowlistEnabled, typeAndVersion, feeQuoterConfig: { maxFeeJuelsPerMsg, linkToken, tokenPriceStalenessThreshold, usdPerUnitGas, ...destChainConfig, }, } } /** * {@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 GRAM 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.getOffRampConfig} */ async getOffRampConfig(offRamp: string, sourceChainSelector: bigint) { try { const { stack } = await this.provider.runMethod(Address.parse(offRamp), 'sourceChainConfig', [ { type: 'int', value: sourceChainSelector }, ]) const router = stack.readAddress().toString() const isEnabled = stack.readBoolean() const minSeqNr = BigInt(stack.readBigNumber().toString()) const isRMNVerificationDisabled = stack.readBoolean() const onRampCell = stack.readCell() const onRampSlice = onRampCell.beginParse() const cellBits = onRampCell.bits.length let onRampBytes: Buffer if (cellBits === 160 || cellBits === 256) { onRampBytes = onRampSlice.loadBuffer(cellBits / 8) } else { const onRampLength = onRampSlice.loadUint(8) onRampBytes = onRampSlice.loadBuffer(onRampLength) } const onRamp = decodeAddress(onRampBytes, networkInfo(sourceChainSelector).family) const [{ stack: cfgStack }, [, , typeAndVersion]] = await Promise.all([ this.provider.runMethod(Address.parse(offRamp), 'config', []), this.typeAndVersion(offRamp), ]) // config() -> chainSelector, feeQuoter, permissionlessExecutionThresholdSeconds const chainSelector = cfgStack.readBigNumber() const feeQuoter = cfgStack.readAddress().toString() const permissionlessExecutionThresholdSeconds = cfgStack.readNumber() return { chainSelector, sourceChainSelector, feeQuoter, permissionlessExecutionThresholdSeconds, router, isEnabled, minSeqNr, isRMNVerificationDisabled, onRamps: [onRamp], typeAndVersion, } } catch (error) { if (isTvmError(error) && error.exitCode === 266) { throw new CCIPSourceChainUnsupportedError(sourceChainSelector, { context: { offRamp }, }) } throw error } } /** {@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: 'GRAM', 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 GRAM 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 known ExecutionState (1-3) if (state < ExecutionState.InProgress || 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)} GRAM) 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(opts: Parameters[0]): Promise { await this.checkSendMessage(opts) const { router, destChainSelector, message } = opts const populatedMessage = buildMessageForDest(message, networkInfo(destChainSelector).family) return getFeeImpl(this, router, destChainSelector, populatedMessage) } /** {@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')) } }