From fc8ac1cae87602789ab3f436e5d316400e857462 Mon Sep 17 00:00:00 2001 From: Patricio Tourne Passarino Date: Thu, 26 Feb 2026 11:32:17 -0300 Subject: [PATCH 1/7] fix: adnl timeout --- pkg/ton/codec/debug/explorer/explorer.go | 123 +++++++++++++++++------ 1 file changed, 93 insertions(+), 30 deletions(-) diff --git a/pkg/ton/codec/debug/explorer/explorer.go b/pkg/ton/codec/debug/explorer/explorer.go index 23267a1d6..317c547a5 100644 --- a/pkg/ton/codec/debug/explorer/explorer.go +++ b/pkg/ton/codec/debug/explorer/explorer.go @@ -291,6 +291,8 @@ type client struct { maxPages uint32 } +func (c *client) resilientAPI() ton.APIClientWrapped { return c.connection.WithRetry(5) } + type Format int const ( @@ -299,6 +301,20 @@ const ( FormatSequenceRaw ) +type toncenterTxResult struct { + Account string `json:"account"` + LT string `json:"lt"` + BlockRef struct { + Workchain int32 `json:"workchain"` + Shard string `json:"shard"` + SeqNo uint32 `json:"seqno"` + } `json:"block_ref"` +} + +type toncenterAPIResponse struct { + Transactions []toncenterTxResult `json:"transactions"` +} + // PrintTrace connects to the specified TON network, retrieves the transaction // by the given source address and transaction hash, and prints the full execution // trace of the transaction, including all outgoing messages and their subsequent @@ -309,6 +325,8 @@ const ( // - txHashStr: The transaction hash in hexadecimal format. // - srcAddrStr: The source address of the transaction in string format. func (c *client) PrintTrace(ctx context.Context, txHashStr string, srcAddrStr string, format Format, knownActors map[string]debug.TypeAndVersion) error { + api := c.resilientAPI() + var senderAddr *address.Address var err error if srcAddrStr == "" { @@ -329,7 +347,7 @@ func (c *client) PrintTrace(ctx context.Context, txHashStr string, srcAddrStr st return fmt.Errorf("failed to decode tx hash: %w", err) } - tx, err := c.findTx(ctx, c.connection, senderAddr, txHash) + tx, err := c.findTx(ctx, api, senderAddr, txHashStr, txHash) if err != nil { return err } @@ -343,14 +361,14 @@ func (c *client) PrintTrace(ctx context.Context, txHashStr string, srcAddrStr st c.lggr.Info("waiting for full trace...") - err = recvMsg.WaitForTrace(ctx, c.connection) + err = recvMsg.WaitForTrace(ctx, api) if err != nil { return fmt.Errorf("failed to wait for trace: %w", err) } c.lggr.Debug("actors before query:\n", knownActors) c.lggr.Info("querying actors") - err = c.queryActors(ctx, &recvMsg, knownActors) + err = c.queryActors(ctx, api, &recvMsg, knownActors) if err != nil { return fmt.Errorf("failed to query actors: %w", err) } @@ -374,51 +392,51 @@ func (c *client) PrintTrace(ctx context.Context, txHashStr string, srcAddrStr st return nil } -func (c *client) queryActors(ctx context.Context, message *tracetracking.ReceivedMessage, knownActors map[string]debug.TypeAndVersion) error { +func (c *client) queryActors(ctx context.Context, api ton.APIClientWrapped, message *tracetracking.ReceivedMessage, knownActors map[string]debug.TypeAndVersion) error { visited := make(map[string]bool) - block, err := c.connection.CurrentMasterchainInfo(ctx) + block, err := api.CurrentMasterchainInfo(ctx) if err != nil { return fmt.Errorf("failed to get masterchain info: %w", err) } - return c.queryActorsReceivedRec(ctx, block, message, knownActors, visited) + return c.queryActorsReceivedRec(ctx, api, block, message, knownActors, visited) } -func (c *client) queryActorsReceivedRec(ctx context.Context, block *ton.BlockIDExt, message *tracetracking.ReceivedMessage, knownActors map[string]debug.TypeAndVersion, visited map[string]bool) error { +func (c *client) queryActorsReceivedRec(ctx context.Context, api ton.APIClientWrapped, block *ton.BlockIDExt, message *tracetracking.ReceivedMessage, knownActors map[string]debug.TypeAndVersion, visited map[string]bool) error { if message.InternalMsg != nil { - err := c.queryActorIfNotVisited(ctx, block, message.InternalMsg.SrcAddr, knownActors, visited) + err := c.queryActorIfNotVisited(ctx, api, block, message.InternalMsg.SrcAddr, knownActors, visited) if err != nil { return err } - err = c.queryActorIfNotVisited(ctx, block, message.InternalMsg.DstAddr, knownActors, visited) + err = c.queryActorIfNotVisited(ctx, api, block, message.InternalMsg.DstAddr, knownActors, visited) if err != nil { return err } - err = c.queryOutgoingMessages(ctx, block, message.OutgoingInternalSentMessages, message.OutgoingInternalReceivedMessages, knownActors, visited) + err = c.queryOutgoingMessages(ctx, api, block, message.OutgoingInternalSentMessages, message.OutgoingInternalReceivedMessages, knownActors, visited) return err } else if message.ExternalMsg != nil { - err := c.queryActorIfNotVisited(ctx, block, message.ExternalMsg.DstAddr, knownActors, visited) + err := c.queryActorIfNotVisited(ctx, api, block, message.ExternalMsg.DstAddr, knownActors, visited) if err != nil { return err } - err = c.queryOutgoingMessages(ctx, block, message.OutgoingInternalSentMessages, message.OutgoingInternalReceivedMessages, knownActors, visited) + err = c.queryOutgoingMessages(ctx, api, block, message.OutgoingInternalSentMessages, message.OutgoingInternalReceivedMessages, knownActors, visited) return err } return fmt.Errorf("unknown message type: %+v", message) } -func (c *client) queryOutgoingMessages(ctx context.Context, block *ton.BlockIDExt, outgoingSentMessages []*tracetracking.SentMessage, outgoingReceivedMessages []*tracetracking.ReceivedMessage, knownActors map[string]debug.TypeAndVersion, visited map[string]bool) error { +func (c *client) queryOutgoingMessages(ctx context.Context, api ton.APIClientWrapped, block *ton.BlockIDExt, outgoingSentMessages []*tracetracking.SentMessage, outgoingReceivedMessages []*tracetracking.ReceivedMessage, knownActors map[string]debug.TypeAndVersion, visited map[string]bool) error { for _, outMsg := range outgoingSentMessages { - err := c.queryActorIfNotVisited(ctx, block, outMsg.InternalMsg.SrcAddr, knownActors, visited) + err := c.queryActorIfNotVisited(ctx, api, block, outMsg.InternalMsg.SrcAddr, knownActors, visited) if err != nil { return err } - err = c.queryActorIfNotVisited(ctx, block, outMsg.InternalMsg.DstAddr, knownActors, visited) + err = c.queryActorIfNotVisited(ctx, api, block, outMsg.InternalMsg.DstAddr, knownActors, visited) if err != nil { return err } } for _, outMsg := range outgoingReceivedMessages { - err := c.queryActorsReceivedRec(ctx, block, outMsg, knownActors, visited) + err := c.queryActorsReceivedRec(ctx, api, block, outMsg, knownActors, visited) if err != nil { return err } @@ -426,7 +444,7 @@ func (c *client) queryOutgoingMessages(ctx context.Context, block *ton.BlockIDEx return nil } -func (c *client) queryActorIfNotVisited(ctx context.Context, block *ton.BlockIDExt, addr *address.Address, knownActors map[string]debug.TypeAndVersion, visited map[string]bool) error { +func (c *client) queryActorIfNotVisited(ctx context.Context, api ton.APIClientWrapped, block *ton.BlockIDExt, addr *address.Address, knownActors map[string]debug.TypeAndVersion, visited map[string]bool) error { c.lggr.Debug("queryActorIfNotVisited", addr.String()) c.lggr.Debug("visited:", visited) c.lggr.Debug("knownActors:", knownActors) @@ -441,7 +459,7 @@ func (c *client) queryActorIfNotVisited(ctx context.Context, block *ton.BlockIDE } c.lggr.Debug("actor not known") var typeVersion common.TypeAndVersion - result, err := c.connection.RunGetMethod(ctx, block, addr, "typeAndVersion") + result, err := api.RunGetMethod(ctx, block, addr, "typeAndVersion") if err != nil { // We don't fail here because many contracts don't implement typeAndVersion return nil // TODO try deducing from code? @@ -463,6 +481,19 @@ func (c *client) queryActorIfNotVisited(ctx context.Context, block *ton.BlockIDE } func (c *client) GetSenderAddressFromTxHash(ctx context.Context, txHashStr string) (*address.Address, error) { + res, err := c.getToncenterTxByHash(ctx, txHashStr) + if err != nil { + return nil, err + } + + addr, err := address.ParseRawAddr(res.Account) + if err != nil { + return nil, fmt.Errorf("failed to parse source address from toncenter response: %w", err) + } + return addr, nil +} + +func (c *client) getToncenterTxByHash(ctx context.Context, txHashStr string) (*toncenterTxResult, error) { // fetch from https://testnet.toncenter.com/api/v3/transactions?hash=txHashStr var baseURL string switch c.net { @@ -473,12 +504,6 @@ func (c *client) GetSenderAddressFromTxHash(ctx context.Context, txHashStr strin default: return nil, fmt.Errorf("unsupported network: %s", c.net) } - type txResult struct { - Account string `json:"account"` - } - type apiResponse struct { - Transactions []txResult `json:"transactions"` - } // Use url.URL for safer URL construction u, err := url.Parse(baseURL) if err != nil { @@ -505,7 +530,7 @@ func (c *client) GetSenderAddressFromTxHash(ctx context.Context, txHashStr strin if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("unexpected status code from toncenter: %d", resp.StatusCode) } - var respData apiResponse + var respData toncenterAPIResponse err = json.NewDecoder(resp.Body).Decode(&respData) if err != nil { return nil, fmt.Errorf("failed to decode toncenter response: %w", err) @@ -513,14 +538,44 @@ func (c *client) GetSenderAddressFromTxHash(ctx context.Context, txHashStr strin if len(respData.Transactions) != 1 { return nil, errors.New("transaction not found in toncenter response") } - addr, err := address.ParseRawAddr(respData.Transactions[0].Account) + + return &respData.Transactions[0], nil +} + +func (c *client) findTxByToncenterMetadata(ctx context.Context, api ton.APIClientWrapped, txHashStr string, txHash []byte, srcAddr *address.Address) (*tlb.Transaction, error) { + res, err := c.getToncenterTxByHash(ctx, txHashStr) + if err != nil { + return nil, err + } + + lt, err := strconv.ParseUint(res.LT, 10, 64) if err != nil { - return nil, fmt.Errorf("failed to parse source address from toncenter response: %w", err) + return nil, fmt.Errorf("failed to parse lt from toncenter response: %w", err) } - return addr, nil + + shard, err := strconv.ParseUint(res.BlockRef.Shard, 16, 64) + if err != nil { + return nil, fmt.Errorf("failed to parse shard from toncenter response: %w", err) + } + + block, err := api.LookupBlock(ctx, res.BlockRef.Workchain, int64(shard), res.BlockRef.SeqNo) + if err != nil { + return nil, fmt.Errorf("failed to lookup block from toncenter metadata: %w", err) + } + + tx, err := api.GetTransaction(ctx, block, srcAddr, lt) + if err != nil { + return nil, fmt.Errorf("failed to fetch transaction from toncenter metadata: %w", err) + } + + if !equalHash(tx.Hash, txHash) { + return nil, errors.New("toncenter metadata lookup returned a different transaction hash") + } + + return tx, nil } -func (c *client) findTx(ctx context.Context, api *ton.APIClient, srcAddr *address.Address, txHash []byte) (*tlb.Transaction, error) { +func (c *client) findTx(ctx context.Context, api ton.APIClientWrapped, srcAddr *address.Address, txHashStr string, txHash []byte) (*tlb.Transaction, error) { block, err := api.GetMasterchainInfo(ctx) if err != nil { return nil, fmt.Errorf("get masterchain info: %w", err) @@ -538,6 +593,9 @@ func (c *client) findTx(ctx context.Context, api *ton.APIClient, srcAddr *addres if err != nil { return nil, fmt.Errorf("get transaction: %w", err) } + if len(txs) == 0 { + return nil, errors.New("transaction not found in searched range. Try increasing --page-size and --max-pages") + } for _, tx := range txs { if equalHash(tx.Hash, txHash) { return tx, nil @@ -548,7 +606,12 @@ func (c *client) findTx(ctx context.Context, api *ton.APIClient, srcAddr *addres maxLT = last.PrevTxLT maxHash = last.PrevTxHash } - return nil, errors.New("transaction not found in searched range. Try increasing --page-size and --max-pages") + tx, err := c.findTxByToncenterMetadata(ctx, api, txHashStr, txHash, srcAddr) + if err == nil { + return tx, nil + } + + return nil, fmt.Errorf("transaction not found in searched range. Try increasing --page-size and --max-pages (fallback failed: %w)", err) } func equalHash(a, b []byte) bool { From 7b6137d99cd351cb5c341064529af5b740dbeeda Mon Sep 17 00:00:00 2001 From: Patricio Tourne Passarino Date: Thu, 26 Feb 2026 11:44:33 -0300 Subject: [PATCH 2/7] feat: open mermaid urls in browser --- pkg/ton/codec/debug/explorer/explorer.go | 35 +++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/pkg/ton/codec/debug/explorer/explorer.go b/pkg/ton/codec/debug/explorer/explorer.go index 317c547a5..19ec75e79 100644 --- a/pkg/ton/codec/debug/explorer/explorer.go +++ b/pkg/ton/codec/debug/explorer/explorer.go @@ -9,6 +9,7 @@ import ( "net/http" "net/url" "os/exec" + "runtime" "strconv" "strings" "time" @@ -387,7 +388,39 @@ func (c *client) PrintTrace(ctx context.Context, txHashStr string, srcAddrStr st default: return errors.New("unknown format") } - c.lggr.Info(debugger.DumpReceived(&recvMsg, c.verbose)) + + output := debugger.DumpReceived(&recvMsg, c.verbose) + if format == FormatSequenceURL { + if err := openInBrowser(ctx, output); err != nil { + return fmt.Errorf("failed to open mermaid url in browser: %w", err) + } + c.lggr.Info("opened mermaid visualization in browser") + return nil + } + + c.lggr.Info(output) + + return nil +} + +func openInBrowser(ctx context.Context, targetURL string) error { + if strings.TrimSpace(targetURL) == "" { + return errors.New("empty url") + } + + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.CommandContext(ctx, "open", targetURL) + case "windows": + cmd = exec.CommandContext(ctx, "rundll32", "url.dll,FileProtocolHandler", targetURL) + default: + cmd = exec.CommandContext(ctx, "xdg-open", targetURL) + } + + if err := cmd.Start(); err != nil { + return err + } return nil } From 4df959b15dcaa10fb3fcfb8a83ed8b183a85d7f0 Mon Sep 17 00:00:00 2001 From: Patricio Tourne Passarino Date: Thu, 26 Feb 2026 11:59:55 -0300 Subject: [PATCH 3/7] feat: any tx hash in a trace will return whole trace. --- pkg/ton/codec/debug/explorer/explorer.go | 114 ++++++++++++++++++++++- 1 file changed, 111 insertions(+), 3 deletions(-) diff --git a/pkg/ton/codec/debug/explorer/explorer.go b/pkg/ton/codec/debug/explorer/explorer.go index 19ec75e79..5a3d94f45 100644 --- a/pkg/ton/codec/debug/explorer/explorer.go +++ b/pkg/ton/codec/debug/explorer/explorer.go @@ -2,6 +2,7 @@ package explorer import ( "context" + "encoding/base64" "encoding/hex" "encoding/json" "errors" @@ -316,6 +317,15 @@ type toncenterAPIResponse struct { Transactions []toncenterTxResult `json:"transactions"` } +type toncenterTraceResponse struct { + Traces []struct { + Trace struct { + TxHash string `json:"tx_hash"` + } `json:"trace"` + TransactionsOrder []string `json:"transactions_order"` + } `json:"traces"` +} + // PrintTrace connects to the specified TON network, retrieves the transaction // by the given source address and transaction hash, and prints the full execution // trace of the transaction, including all outgoing messages and their subsequent @@ -327,28 +337,45 @@ type toncenterAPIResponse struct { // - srcAddrStr: The source address of the transaction in string format. func (c *client) PrintTrace(ctx context.Context, txHashStr string, srcAddrStr string, format Format, knownActors map[string]debug.TypeAndVersion) error { api := c.resilientAPI() + effectiveTxHash := txHashStr + rootTxHash, rootErr := c.getTraceRootTxHash(ctx, txHashStr) + if rootErr == nil && rootTxHash != "" { + effectiveTxHash = rootTxHash + if rootTxHash != txHashStr { + c.lggr.Info("resolved input transaction to trace root", "input_tx_hash", txHashStr, "root_tx_hash", rootTxHash) + } + } else if rootErr != nil { + c.lggr.Debug("failed to resolve trace root tx hash, continuing with provided tx", "tx_hash", txHashStr, "error", rootErr) + } var senderAddr *address.Address var err error if srcAddrStr == "" { c.lggr.Debug("source address not provided, attempting to fetch from toncenter by hash...") - senderAddr, err = c.GetSenderAddressFromTxHash(ctx, txHashStr) + senderAddr, err = c.GetSenderAddressFromTxHash(ctx, effectiveTxHash) if err != nil { return fmt.Errorf("failed to get sender address from tx hash: %w", err) } c.lggr.Debug("source address found:", senderAddr.String()) + } else if effectiveTxHash != txHashStr { + // User-provided address may correspond to a non-root tx. Prefer root tx account. + senderAddr, err = c.GetSenderAddressFromTxHash(ctx, effectiveTxHash) + if err != nil { + return fmt.Errorf("failed to get root sender address from tx hash: %w", err) + } + c.lggr.Debug("overriding provided source address with trace root account", senderAddr.String()) } else { senderAddr, err = address.ParseAddr(srcAddrStr) if err != nil { return fmt.Errorf("failed to parse transaction address: %w", err) } } - txHash, err := hex.DecodeString(txHashStr) + txHash, err := decodeTxHash(effectiveTxHash) if err != nil { return fmt.Errorf("failed to decode tx hash: %w", err) } - tx, err := c.findTx(ctx, api, senderAddr, txHashStr, txHash) + tx, err := c.findTx(ctx, api, senderAddr, effectiveTxHash, txHash) if err != nil { return err } @@ -403,6 +430,87 @@ func (c *client) PrintTrace(ctx context.Context, txHashStr string, srcAddrStr st return nil } +func decodeTxHash(txHash string) ([]byte, error) { + if after, ok := strings.CutPrefix(txHash, "0x"); ok { + txHash = after + } + + if raw, err := hex.DecodeString(txHash); err == nil { + return raw, nil + } + + if raw, err := base64.StdEncoding.DecodeString(txHash); err == nil { + return raw, nil + } + + if raw, err := base64.URLEncoding.DecodeString(txHash); err == nil { + return raw, nil + } + + if raw, err := base64.RawURLEncoding.DecodeString(txHash); err == nil { + return raw, nil + } + + return nil, fmt.Errorf("unsupported tx hash format: %s", txHash) +} + +func (c *client) getTraceRootTxHash(ctx context.Context, txHashStr string) (string, error) { + var baseURL string + switch c.net { + case "mainnet": + baseURL = "https://toncenter.com/api/v3/traces" + case "testnet": + baseURL = "https://testnet.toncenter.com/api/v3/traces" + default: + return "", fmt.Errorf("unsupported network for trace index lookup: %s", c.net) + } + + u, err := url.Parse(baseURL) + if err != nil { + return "", fmt.Errorf("invalid trace endpoint url: %w", err) + } + + q := u.Query() + q.Set("tx_hash", txHashStr) + u.RawQuery = q.Encode() + + httpClient := &http.Client{Timeout: 30 * time.Second} + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return "", fmt.Errorf("failed to create trace request: %w", err) + } + + resp, err := httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("failed to fetch trace from toncenter: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("unexpected status code from trace endpoint: %d", resp.StatusCode) + } + + var traceResp toncenterTraceResponse + if err := json.NewDecoder(resp.Body).Decode(&traceResp); err != nil { + return "", fmt.Errorf("failed to decode trace response: %w", err) + } + + if len(traceResp.Traces) == 0 { + return "", errors.New("no trace found for transaction") + } + + trace := traceResp.Traces[0] + if len(trace.TransactionsOrder) > 0 && trace.TransactionsOrder[0] != "" { + return trace.TransactionsOrder[0], nil + } + + if trace.Trace.TxHash != "" { + return trace.Trace.TxHash, nil + } + + return "", errors.New("trace root hash missing in trace response") +} + func openInBrowser(ctx context.Context, targetURL string) error { if strings.TrimSpace(targetURL) == "" { return errors.New("empty url") From a71a0895289a77d7b6782dc7528291fda288549f Mon Sep 17 00:00:00 2001 From: Patricio Tourne Passarino Date: Thu, 26 Feb 2026 15:12:32 -0300 Subject: [PATCH 4/7] ref: split cli files, document architecture, small fixes --- .../.misc/dev-guides/explorer/architecture.md | 68 ++ docs/.misc/dev-guides/explorer/development.md | 6 +- docs/.misc/dev-guides/explorer/usage.md | 8 +- docs/README.md | 3 + pkg/ton/codec/debug/explorer/browser.go | 27 + pkg/ton/codec/debug/explorer/cli_args.go | 44 ++ pkg/ton/codec/debug/explorer/explorer.go | 606 ++---------------- pkg/ton/codec/debug/explorer/format.go | 33 + .../codec/debug/explorer/network_connect.go | 65 ++ .../debug/explorer/network_mylocalton.go | 99 +++ pkg/ton/codec/debug/explorer/tx_lookup.go | 277 ++++++++ .../visualizations/sequence/sanitizer.go | 37 ++ .../visualizations/sequence/sanitizer_test.go | 36 ++ .../sequence/sequence_diagram.go | 2 +- 14 files changed, 747 insertions(+), 564 deletions(-) create mode 100644 docs/.misc/dev-guides/explorer/architecture.md create mode 100644 pkg/ton/codec/debug/explorer/browser.go create mode 100644 pkg/ton/codec/debug/explorer/cli_args.go create mode 100644 pkg/ton/codec/debug/explorer/format.go create mode 100644 pkg/ton/codec/debug/explorer/network_connect.go create mode 100644 pkg/ton/codec/debug/explorer/network_mylocalton.go create mode 100644 pkg/ton/codec/debug/explorer/tx_lookup.go diff --git a/docs/.misc/dev-guides/explorer/architecture.md b/docs/.misc/dev-guides/explorer/architecture.md new file mode 100644 index 000000000..7684a61eb --- /dev/null +++ b/docs/.misc/dev-guides/explorer/architecture.md @@ -0,0 +1,68 @@ +# TON Explorer Architecture + +This document describes the explorer command architecture in `pkg/ton/codec/debug/explorer`. + +## Overview + +The explorer flow is organized into five stages: + +1. **CLI input normalization** +2. **Network/API connection setup** +3. **Transaction and trace discovery** +4. **Trace enrichment (actors/contracts)** +5. **Rendering/output** + +## Module layout + +- `explorer.go`: command wiring, `client` lifecycle, trace orchestration, actor discovery. +- `cli_args.go`: positional argument and URL/hash parsing integration (`parseCLIInput`). +- `utils.go`: explorer URL parsing (`ParseURL`). +- `format.go`: visualization format validation (`parseFormat`). +- `network_connect.go`: TON connection bootstrap (`connect`). +- `network_mylocalton.go`: Docker inspection helpers for `mylocalton`. +- `tx_lookup.go`: tx hash decoding, toncenter metadata lookups, tx search/fallback logic. +- `browser.go`: OS-specific browser opening for sequence URL mode. + +## Request lifecycle + +`GenerateExplorerCmd` parses args and flags, creates a `client` with `Connect`, and runs `PrintTrace`. + +`PrintTrace` performs: + +1. Resolve root tx hash from toncenter when supported (`mainnet`/`testnet`). +2. Resolve sender address: + - from user input when provided, + - from toncenter when hash-only mode is used on supported networks. +3. Locate transaction from account history (paged liteclient scan), with toncenter metadata fallback when available. +4. Convert transaction to trace root (`tracetracking.MapToReceivedMessage`) and wait for full trace (`WaitForTrace`). +5. Query contract actors via `typeAndVersion` getter. +6. Render either tree or sequence output. +7. For sequence URL mode, open Mermaid URL in browser. + +## Toncenter behavior + +Toncenter is treated as an optional dependency by network: + +- `mainnet`/`testnet`: toncenter is used for trace-root and tx metadata resolution. +- `mylocalton` or custom config URL networks: toncenter fallback is unavailable. + - Hash-only mode requires explicit source address in these environments. + +## Extension points + +For maintainability, keep future changes aligned with existing seams: + +- Input parsing changes in `cli_args.go`/`utils.go`. +- New visualization output options in `format.go` + rendering branch in `PrintTrace`. +- Network-specific bootstrap logic in `network_connect.go`. +- External metadata providers in `tx_lookup.go`. +- Browser side-effects in `browser.go`. + +## Compatibility contract + +Current CLI contract intentionally remains: + +- `explorer ` +- `explorer
` +- `explorer ` (works when address can be resolved via toncenter) + +`--address` and `--tx` flags were removed because they were unused and misleading. diff --git a/docs/.misc/dev-guides/explorer/development.md b/docs/.misc/dev-guides/explorer/development.md index 4d2300401..0040c047f 100644 --- a/docs/.misc/dev-guides/explorer/development.md +++ b/docs/.misc/dev-guides/explorer/development.md @@ -1,6 +1,8 @@ # TON Explorer Development Guide -For adding support to more contracts, you need to register your decoder in [`defaultDecoders`](../../../../pkg/ton/debug/pretty_print.go). Decoders implement [`ContractDecoder`](../../../../pkg/ton/debug/lib/lib.go) interface: +For explorer architecture and module boundaries, read [TON Explorer Architecture](./architecture.md). + +For adding support to more contracts, you need to register your decoder in [`defaultDecoders`](../../../../pkg/ton/codec/debug/pretty_print.go). Decoders implement [`ContractDecoder`](../../../../pkg/ton/codec/debug/lib/lib.go) interface: ```go type ContractDecoder interface { @@ -30,7 +32,7 @@ type BodyInfo interface { } ``` -Your decoder should go in `pkg/ton/debug/decoders/` package. If it is a ccip contract, then in `pkg/ton/debug/decoders/ccip`. E.g. `pkg/ton/debug/decoders/ccip/feequoter/feequoter.go`. +Your decoder should go in `pkg/ton/codec/debug/decoders/` package. If it is a ccip contract, then in `pkg/ton/codec/debug/decoders/ccip`. E.g. `pkg/ton/codec/debug/decoders/ccip/feequoter/feequoter.go`. I suggest not placing any business logic in the decoder. Instead, create a separate package for that, e.g. `pkg/ccip/bindings/feequoter/codec.go` and use it from the decoder. diff --git a/docs/.misc/dev-guides/explorer/usage.md b/docs/.misc/dev-guides/explorer/usage.md index 284322aae..6beb45822 100644 --- a/docs/.misc/dev-guides/explorer/usage.md +++ b/docs/.misc/dev-guides/explorer/usage.md @@ -2,13 +2,15 @@ Command-line tool for analyzing TON blockchain transactions and traces. +Read [TON Explorer Architecture](./architecture.md) for internal module layout and execution flow. + ## Usage Three ways to run: 1. **URL**: `./explorer ` 2. **Hash + Address**: `./explorer
` -3. **Hash only**: `./explorer ` (testnet/mainnet only) +3. **Hash only**: `./explorer ` (testnet/mainnet only unless sender address is provided separately) ## Run with Nix @@ -37,7 +39,7 @@ go build # Hash + address ./explorer
[--net testnet|mainnet|mylocalton|http://custom-domain/global.config.json] -# Hash only (auto-resolves address) +# Hash only (auto-resolves address via toncenter on testnet/mainnet) ./explorer [--net testnet|mainnet|mylocalton|http://custom-domain/global.config.json] ``` @@ -72,6 +74,8 @@ Display message trace as a tree structure with `--visualization tree`. --page-size 10 --max-pages 10 # Control transaction search pagination ``` +Note: `--address` and `--tx` flags are not supported; use positional arguments. + ## Environment injection The same cli is exposed in [chainlink-deployments's repo](https://github.com/smartcontractkit/chainlink-deployments/tree/main/domains/ccip/cmd) which injects contract metadata from the DataStore. diff --git a/docs/README.md b/docs/README.md index 9da387cd6..9678ab9d1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,9 @@ - [Nix - Getting Started](.misc/dev-guides/nix/getting-started.md) - [Nix - Builds](.misc/dev-guides/nix/builds.md) +- [Explorer - Usage](.misc/dev-guides/explorer/usage.md) +- [Explorer - Architecture](.misc/dev-guides/explorer/architecture.md) +- [Explorer - Development](.misc/dev-guides/explorer/development.md) ## CCIP Product E2E Tests diff --git a/pkg/ton/codec/debug/explorer/browser.go b/pkg/ton/codec/debug/explorer/browser.go new file mode 100644 index 000000000..6af09dca6 --- /dev/null +++ b/pkg/ton/codec/debug/explorer/browser.go @@ -0,0 +1,27 @@ +package explorer + +import ( + "context" + "errors" + "os/exec" + "runtime" + "strings" +) + +func openInBrowser(ctx context.Context, targetURL string) error { + if strings.TrimSpace(targetURL) == "" { + return errors.New("empty url") + } + + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.CommandContext(ctx, "open", targetURL) + case "windows": + cmd = exec.CommandContext(ctx, "rundll32", "url.dll,FileProtocolHandler", targetURL) + default: + cmd = exec.CommandContext(ctx, "xdg-open", targetURL) + } + + return cmd.Start() +} diff --git a/pkg/ton/codec/debug/explorer/cli_args.go b/pkg/ton/codec/debug/explorer/cli_args.go new file mode 100644 index 000000000..92b003458 --- /dev/null +++ b/pkg/ton/codec/debug/explorer/cli_args.go @@ -0,0 +1,44 @@ +package explorer + +import ( + "encoding/hex" + "errors" + "fmt" + "strings" + + "github.com/spf13/cobra" +) + +type cliInput struct { + txHash string + address string + net string +} + +func parseCLIInput(cmd *cobra.Command, args []string) (cliInput, error) { + urlOrTx := args[0] + txHash, address, parsedNet, parseURLErr := ParseURL(urlOrTx) + if parseURLErr == nil { + if cmd.Flags().Changed("net") { + return cliInput{}, errors.New("cannot specify network flag when using URL") + } + if len(args) == 2 { + address = args[1] + } + return cliInput{txHash: txHash, address: address, net: parsedNet}, nil + } + + if len(urlOrTx) != 64 && (len(urlOrTx) != 66 || !strings.HasPrefix(urlOrTx, "0x")) { + return cliInput{}, fmt.Errorf("failed to parse URL: %w", parseURLErr) + } + + if _, err := hex.DecodeString(strings.TrimPrefix(urlOrTx, "0x")); err != nil { + return cliInput{}, fmt.Errorf("invalid transaction hash or url: %w", err) + } + + if len(args) == 2 { + address = args[1] + } + + return cliInput{txHash: urlOrTx, address: address}, nil +} diff --git a/pkg/ton/codec/debug/explorer/explorer.go b/pkg/ton/codec/debug/explorer/explorer.go index 5a3d94f45..71d7f17e0 100644 --- a/pkg/ton/codec/debug/explorer/explorer.go +++ b/pkg/ton/codec/debug/explorer/explorer.go @@ -2,24 +2,12 @@ package explorer import ( "context" - "encoding/base64" - "encoding/hex" - "encoding/json" "errors" "fmt" - "net/http" - "net/url" - "os/exec" - "runtime" - "strconv" - "strings" - "time" "github.com/Masterminds/semver/v3" "github.com/spf13/cobra" "github.com/xssnick/tonutils-go/address" - "github.com/xssnick/tonutils-go/liteclient" - "github.com/xssnick/tonutils-go/tlb" "github.com/xssnick/tonutils-go/ton" "go.uber.org/zap/zapcore" @@ -33,14 +21,12 @@ import ( func GenerateExplorerCmd(lggr *logger.Logger, contracts map[string]debug.TypeAndVersion, client *ton.APIClient) *cobra.Command { var ( - destAddressStr string - txHashStr string - net string - verbose bool - pageSize uint32 - maxPages uint32 - visualization string - format string + net string + verbose bool + pageSize uint32 + maxPages uint32 + visualization string + format string ) cmd := &cobra.Command{ @@ -81,32 +67,12 @@ Arguments: if client != nil && cmd.Flags().Changed("net") { return errors.New("cannot specify network flag when using existing client") } - var txHash, address, parsedNet string - - urlOrTx := args[0] - var parseURLErr error - txHash, address, parsedNet, parseURLErr = ParseURL(urlOrTx) - if parseURLErr == nil { - if cmd.Root().Flags().Changed("net") { - return errors.New("cannot specify network flag when using URL") - } - net = parsedNet - } else { - // Not a URL, treat as tx-hash - if len(urlOrTx) != 64 && (len(urlOrTx) != 66 || !strings.HasPrefix(urlOrTx, "0x")) { - return fmt.Errorf("failed to parse URL: %w", parseURLErr) - } - - _, err = hex.DecodeString(strings.TrimPrefix(urlOrTx, "0x")) - if err != nil { - return fmt.Errorf("invalid transaction hash or url: %w", err) - } - txHash = urlOrTx - } - if len(args) == 2 { - address = args[1] + input, err := parseCLIInput(cmd, args) + if err != nil { + return err } + net = input.net ctx := context.Background() client, err := Connect(log, client, net, verbose, pageSize, maxPages) @@ -117,7 +83,7 @@ Arguments: if err != nil { return fmt.Errorf("failed to parse format: %w", err) } - err = client.PrintTrace(ctx, txHash, address, explorerFormat, contracts) + err = client.PrintTrace(ctx, input.txHash, input.address, explorerFormat, contracts) if err != nil { return fmt.Errorf("failed to execute trace: %w", err) } @@ -125,10 +91,8 @@ Arguments: }, } - cmd.Flags().StringVarP(&destAddressStr, "address", "a", "", "Destination address in base64 (optional if provided as argument)") cmd.Flags().StringVarP(&visualization, "visualization", "V", "sequence", "Visualization format (sequence or tree)") cmd.Flags().StringVarP(&format, "format", "f", "", "Sequence visualization format (url or raw) (only for sequence visualization)") - cmd.Flags().StringVarP(&txHashStr, "tx", "t", "", "Transaction hash in hex (optional if provided as argument)") cmd.Flags().StringVarP(&net, "net", "n", "testnet", "TON network (mainnet, testnet, mylocalton, or http://domain/x.global.config.json)") if lggr == nil { cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "Shows full body of unmatched messages") @@ -139,125 +103,8 @@ Arguments: return cmd } -func parseFormat(visualization string, format string) (Format, error) { - switch visualization { - case "tree": - if format != "" { - return Format(0), errors.New("format option is not applicable for tree visualization") - } - return FormatTree, nil - case "sequence": - switch format { - case "", "url": - return FormatSequenceURL, nil - case "raw": - return FormatSequenceRaw, nil - } - return Format(0), fmt.Errorf("invalid sequence format: %s", format) - } - return Format(0), fmt.Errorf("invalid visualization format: %s", format) -} - -// ContainerInspect represents the structure returned by docker inspect -type ContainerInspect struct { - ID string `json:"Id"` - State struct { - Running bool `json:"Running"` - } `json:"State"` - Config struct { - Image string `json:"Image"` - } `json:"Config"` - NetworkSettings struct { - Ports map[string][]struct { - HostIP string `json:"HostIp"` - HostPort string `json:"HostPort"` - } `json:"Ports"` - } `json:"NetworkSettings"` -} - -// findMylocaltonContainer finds a running mylocalton container and returns its ID -func findMylocaltonContainer(ctx context.Context) (string, error) { - cmd := exec.CommandContext(ctx, "docker", "ps", "--format", "{{.ID}}\t{{.Image}}", "--filter", "status=running") - output, err := cmd.Output() - if err != nil { - return "", fmt.Errorf("failed to list docker containers: %w", err) - } - - lines := strings.Split(strings.TrimSpace(string(output)), "\n") - for _, line := range lines { - if line == "" { - continue - } - parts := strings.Split(line, "\t") - if len(parts) != 2 { - continue - } - containerID := parts[0] - image := parts[1] - - // Look for mylocalton containers, but exclude explorer - if strings.Contains(image, "mylocalton-docker") && !strings.Contains(image, "mylocalton-docker-explorer") { - return containerID, nil - } - } - - return "", errors.New("no running mylocalton container found") -} - -// inspectContainer runs docker inspect on the given container ID -func inspectContainer(ctx context.Context, containerID string) (*ContainerInspect, error) { - cmd := exec.CommandContext(ctx, "docker", "inspect", containerID) - output, err := cmd.CombinedOutput() - if err != nil { - if strings.Contains(string(output), "No such object") || strings.Contains(string(output), "No such container") { - return nil, fmt.Errorf("container %s does not exist", containerID) - } - return nil, fmt.Errorf("docker inspect failed: %w\nOutput: %s", err, string(output)) - } - - var inspects []ContainerInspect - if err := json.Unmarshal(output, &inspects); err != nil { - return nil, fmt.Errorf("failed to parse docker inspect output: %w", err) - } - - if len(inspects) == 0 { - return nil, fmt.Errorf("container %s not found", containerID) - } - - inspect := &inspects[0] - - if !inspect.State.Running { - return nil, fmt.Errorf("container %s exists but is not running", containerID) - } - - return inspect, nil -} - -// getPortMapping extracts the host port that maps to a given container port -func getPortMapping(inspect *ContainerInspect, containerPort string) (string, error) { - portKey := containerPort + "/tcp" - ports, exists := inspect.NetworkSettings.Ports[portKey] - if !exists || len(ports) == 0 { - return "", fmt.Errorf("no port mapping found for container port %s", containerPort) - } - - // Return the first host port mapping - hostPort := ports[0].HostPort - if hostPort == "" { - return "", fmt.Errorf("empty host port mapping for container port %s", containerPort) - } - - return hostPort, nil -} - // Connect establishes a connection to the specified TON network and returns an // explorer instance for tracing transactions. -// -// Parameters: -// - net: The TON network to connect to (e.g., "mainnet", "testnet", "mylocalton", "http://127.0.0.1:8000/localhost.global.config.json"). -// - verbose: Whether to enable verbose output. -// - pageSize: The number of transactions to fetch per page. -// - maxPages: The maximum number of pages to fetch. func Connect(lggr logger.Logger, apiClient *ton.APIClient, net string, verbose bool, pageSize uint32, maxPages uint32) (*client, error) { if apiClient == nil { var err error @@ -295,62 +142,27 @@ type client struct { func (c *client) resilientAPI() ton.APIClientWrapped { return c.connection.WithRetry(5) } -type Format int - -const ( - FormatTree Format = iota - FormatSequenceURL - FormatSequenceRaw -) - -type toncenterTxResult struct { - Account string `json:"account"` - LT string `json:"lt"` - BlockRef struct { - Workchain int32 `json:"workchain"` - Shard string `json:"shard"` - SeqNo uint32 `json:"seqno"` - } `json:"block_ref"` -} - -type toncenterAPIResponse struct { - Transactions []toncenterTxResult `json:"transactions"` -} - -type toncenterTraceResponse struct { - Traces []struct { - Trace struct { - TxHash string `json:"tx_hash"` - } `json:"trace"` - TransactionsOrder []string `json:"transactions_order"` - } `json:"traces"` -} - -// PrintTrace connects to the specified TON network, retrieves the transaction -// by the given source address and transaction hash, and prints the full execution -// trace of the transaction, including all outgoing messages and their subsequent -// messages. -// -// Parameters: -// - ctx: The context for managing request deadlines and cancellation. -// - txHashStr: The transaction hash in hexadecimal format. -// - srcAddrStr: The source address of the transaction in string format. func (c *client) PrintTrace(ctx context.Context, txHashStr string, srcAddrStr string, format Format, knownActors map[string]debug.TypeAndVersion) error { api := c.resilientAPI() effectiveTxHash := txHashStr - rootTxHash, rootErr := c.getTraceRootTxHash(ctx, txHashStr) - if rootErr == nil && rootTxHash != "" { - effectiveTxHash = rootTxHash - if rootTxHash != txHashStr { - c.lggr.Info("resolved input transaction to trace root", "input_tx_hash", txHashStr, "root_tx_hash", rootTxHash) + if c.supportsToncenter() { + rootTxHash, rootErr := c.getTraceRootTxHash(ctx, txHashStr) + if rootErr == nil && rootTxHash != "" { + effectiveTxHash = rootTxHash + if rootTxHash != txHashStr { + c.lggr.Info("resolved input transaction to trace root", "input_tx_hash", txHashStr, "root_tx_hash", rootTxHash) + } + } else if rootErr != nil { + c.lggr.Debug("failed to resolve trace root tx hash, continuing with provided tx", "tx_hash", txHashStr, "error", rootErr) } - } else if rootErr != nil { - c.lggr.Debug("failed to resolve trace root tx hash, continuing with provided tx", "tx_hash", txHashStr, "error", rootErr) } var senderAddr *address.Address var err error if srcAddrStr == "" { + if !c.supportsToncenter() { + return fmt.Errorf("source address is required for network %s when toncenter metadata is unavailable", c.net) + } c.lggr.Debug("source address not provided, attempting to fetch from toncenter by hash...") senderAddr, err = c.GetSenderAddressFromTxHash(ctx, effectiveTxHash) if err != nil { @@ -358,24 +170,30 @@ func (c *client) PrintTrace(ctx context.Context, txHashStr string, srcAddrStr st } c.lggr.Debug("source address found:", senderAddr.String()) } else if effectiveTxHash != txHashStr { - // User-provided address may correspond to a non-root tx. Prefer root tx account. - senderAddr, err = c.GetSenderAddressFromTxHash(ctx, effectiveTxHash) - if err != nil { - return fmt.Errorf("failed to get root sender address from tx hash: %w", err) + if c.supportsToncenter() { + senderAddr, err = c.GetSenderAddressFromTxHash(ctx, effectiveTxHash) + if err != nil { + return fmt.Errorf("failed to get root sender address from tx hash: %w", err) + } + c.lggr.Debug("overriding provided source address with trace root account", senderAddr.String()) + } else { + senderAddr, err = address.ParseAddr(srcAddrStr) + if err != nil { + return fmt.Errorf("failed to parse transaction address: %w", err) + } } - c.lggr.Debug("overriding provided source address with trace root account", senderAddr.String()) } else { senderAddr, err = address.ParseAddr(srcAddrStr) if err != nil { return fmt.Errorf("failed to parse transaction address: %w", err) } } - txHash, err := decodeTxHash(effectiveTxHash) + decodedTxHash, err := decodeTxHash(effectiveTxHash) if err != nil { return fmt.Errorf("failed to decode tx hash: %w", err) } - tx, err := c.findTx(ctx, api, senderAddr, effectiveTxHash, txHash) + tx, err := c.findTx(ctx, api, senderAddr, effectiveTxHash, decodedTxHash) if err != nil { return err } @@ -388,22 +206,17 @@ func (c *client) PrintTrace(ctx context.Context, txHashStr string, srcAddrStr st } c.lggr.Info("waiting for full trace...") - - err = recvMsg.WaitForTrace(ctx, api) - if err != nil { + if err = recvMsg.WaitForTrace(ctx, api); err != nil { return fmt.Errorf("failed to wait for trace: %w", err) } c.lggr.Debug("actors before query:\n", knownActors) c.lggr.Info("querying actors") - err = c.queryActors(ctx, api, &recvMsg, knownActors) - if err != nil { + if err = c.queryActors(ctx, api, &recvMsg, knownActors); err != nil { return fmt.Errorf("failed to query actors: %w", err) } c.lggr.Debug("actors after query:\n", knownActors) - c.lggr.Info("full trace received:") - var debugger debug.DebuggerEnvironment switch format { case FormatSequenceURL: @@ -418,7 +231,7 @@ func (c *client) PrintTrace(ctx context.Context, txHashStr string, srcAddrStr st output := debugger.DumpReceived(&recvMsg, c.verbose) if format == FormatSequenceURL { - if err := openInBrowser(ctx, output); err != nil { + if err = openInBrowser(ctx, output); err != nil { return fmt.Errorf("failed to open mermaid url in browser: %w", err) } c.lggr.Info("opened mermaid visualization in browser") @@ -426,110 +239,6 @@ func (c *client) PrintTrace(ctx context.Context, txHashStr string, srcAddrStr st } c.lggr.Info(output) - - return nil -} - -func decodeTxHash(txHash string) ([]byte, error) { - if after, ok := strings.CutPrefix(txHash, "0x"); ok { - txHash = after - } - - if raw, err := hex.DecodeString(txHash); err == nil { - return raw, nil - } - - if raw, err := base64.StdEncoding.DecodeString(txHash); err == nil { - return raw, nil - } - - if raw, err := base64.URLEncoding.DecodeString(txHash); err == nil { - return raw, nil - } - - if raw, err := base64.RawURLEncoding.DecodeString(txHash); err == nil { - return raw, nil - } - - return nil, fmt.Errorf("unsupported tx hash format: %s", txHash) -} - -func (c *client) getTraceRootTxHash(ctx context.Context, txHashStr string) (string, error) { - var baseURL string - switch c.net { - case "mainnet": - baseURL = "https://toncenter.com/api/v3/traces" - case "testnet": - baseURL = "https://testnet.toncenter.com/api/v3/traces" - default: - return "", fmt.Errorf("unsupported network for trace index lookup: %s", c.net) - } - - u, err := url.Parse(baseURL) - if err != nil { - return "", fmt.Errorf("invalid trace endpoint url: %w", err) - } - - q := u.Query() - q.Set("tx_hash", txHashStr) - u.RawQuery = q.Encode() - - httpClient := &http.Client{Timeout: 30 * time.Second} - req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) - if err != nil { - return "", fmt.Errorf("failed to create trace request: %w", err) - } - - resp, err := httpClient.Do(req) - if err != nil { - return "", fmt.Errorf("failed to fetch trace from toncenter: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("unexpected status code from trace endpoint: %d", resp.StatusCode) - } - - var traceResp toncenterTraceResponse - if err := json.NewDecoder(resp.Body).Decode(&traceResp); err != nil { - return "", fmt.Errorf("failed to decode trace response: %w", err) - } - - if len(traceResp.Traces) == 0 { - return "", errors.New("no trace found for transaction") - } - - trace := traceResp.Traces[0] - if len(trace.TransactionsOrder) > 0 && trace.TransactionsOrder[0] != "" { - return trace.TransactionsOrder[0], nil - } - - if trace.Trace.TxHash != "" { - return trace.Trace.TxHash, nil - } - - return "", errors.New("trace root hash missing in trace response") -} - -func openInBrowser(ctx context.Context, targetURL string) error { - if strings.TrimSpace(targetURL) == "" { - return errors.New("empty url") - } - - var cmd *exec.Cmd - switch runtime.GOOS { - case "darwin": - cmd = exec.CommandContext(ctx, "open", targetURL) - case "windows": - cmd = exec.CommandContext(ctx, "rundll32", "url.dll,FileProtocolHandler", targetURL) - default: - cmd = exec.CommandContext(ctx, "xdg-open", targetURL) - } - - if err := cmd.Start(); err != nil { - return err - } - return nil } @@ -552,15 +261,14 @@ func (c *client) queryActorsReceivedRec(ctx context.Context, api ton.APIClientWr if err != nil { return err } - err = c.queryOutgoingMessages(ctx, api, block, message.OutgoingInternalSentMessages, message.OutgoingInternalReceivedMessages, knownActors, visited) - return err - } else if message.ExternalMsg != nil { + return c.queryOutgoingMessages(ctx, api, block, message.OutgoingInternalSentMessages, message.OutgoingInternalReceivedMessages, knownActors, visited) + } + if message.ExternalMsg != nil { err := c.queryActorIfNotVisited(ctx, api, block, message.ExternalMsg.DstAddr, knownActors, visited) if err != nil { return err } - err = c.queryOutgoingMessages(ctx, api, block, message.OutgoingInternalSentMessages, message.OutgoingInternalReceivedMessages, knownActors, visited) - return err + return c.queryOutgoingMessages(ctx, api, block, message.OutgoingInternalSentMessages, message.OutgoingInternalReceivedMessages, knownActors, visited) } return fmt.Errorf("unknown message type: %+v", message) } @@ -577,8 +285,7 @@ func (c *client) queryOutgoingMessages(ctx context.Context, api ton.APIClientWra } } for _, outMsg := range outgoingReceivedMessages { - err := c.queryActorsReceivedRec(ctx, api, block, outMsg, knownActors, visited) - if err != nil { + if err := c.queryActorsReceivedRec(ctx, api, block, outMsg, knownActors, visited); err != nil { return err } } @@ -587,28 +294,20 @@ func (c *client) queryOutgoingMessages(ctx context.Context, api ton.APIClientWra func (c *client) queryActorIfNotVisited(ctx context.Context, api ton.APIClientWrapped, block *ton.BlockIDExt, addr *address.Address, knownActors map[string]debug.TypeAndVersion, visited map[string]bool) error { c.lggr.Debug("queryActorIfNotVisited", addr.String()) - c.lggr.Debug("visited:", visited) - c.lggr.Debug("knownActors:", knownActors) if visited[addr.String()] { - c.lggr.Debug("already visited", addr.String()) return nil } if _, known := knownActors[addr.String()]; known { visited[addr.String()] = true - c.lggr.Debug("actor found in knownActors", addr.String()) return nil } - c.lggr.Debug("actor not known") - var typeVersion common.TypeAndVersion + result, err := api.RunGetMethod(ctx, block, addr, "typeAndVersion") if err != nil { - // We don't fail here because many contracts don't implement typeAndVersion - return nil // TODO try deducing from code? + return nil } - defer func() { - }() - typeVersion, err = common.GetTypeAndVersion.Decoder.Decode(result) + typeVersion, err := common.GetTypeAndVersion.Decoder.Decode(result) if err != nil { return fmt.Errorf("failed to parse typeAndVersion: %w", err) } @@ -620,214 +319,3 @@ func (c *client) queryActorIfNotVisited(ctx context.Context, api ton.APIClientWr } return nil } - -func (c *client) GetSenderAddressFromTxHash(ctx context.Context, txHashStr string) (*address.Address, error) { - res, err := c.getToncenterTxByHash(ctx, txHashStr) - if err != nil { - return nil, err - } - - addr, err := address.ParseRawAddr(res.Account) - if err != nil { - return nil, fmt.Errorf("failed to parse source address from toncenter response: %w", err) - } - return addr, nil -} - -func (c *client) getToncenterTxByHash(ctx context.Context, txHashStr string) (*toncenterTxResult, error) { - // fetch from https://testnet.toncenter.com/api/v3/transactions?hash=txHashStr - var baseURL string - switch c.net { - case "mainnet": - baseURL = "https://toncenter.com/api/v3/transactions" - case "testnet": - baseURL = "https://testnet.toncenter.com/api/v3/transactions" - default: - return nil, fmt.Errorf("unsupported network: %s", c.net) - } - // Use url.URL for safer URL construction - u, err := url.Parse(baseURL) - if err != nil { - return nil, fmt.Errorf("invalid base URL: %w", err) - } - - // Add query parameters safely - q := u.Query() - q.Set("hash", txHashStr) // No need for manual encoding when using url.Values - u.RawQuery = q.Encode() - - // Create request with context and timeout - client := &http.Client{Timeout: 30 * time.Second} - req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - resp, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to fetch transaction info from toncenter: %w", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("unexpected status code from toncenter: %d", resp.StatusCode) - } - var respData toncenterAPIResponse - err = json.NewDecoder(resp.Body).Decode(&respData) - if err != nil { - return nil, fmt.Errorf("failed to decode toncenter response: %w", err) - } - if len(respData.Transactions) != 1 { - return nil, errors.New("transaction not found in toncenter response") - } - - return &respData.Transactions[0], nil -} - -func (c *client) findTxByToncenterMetadata(ctx context.Context, api ton.APIClientWrapped, txHashStr string, txHash []byte, srcAddr *address.Address) (*tlb.Transaction, error) { - res, err := c.getToncenterTxByHash(ctx, txHashStr) - if err != nil { - return nil, err - } - - lt, err := strconv.ParseUint(res.LT, 10, 64) - if err != nil { - return nil, fmt.Errorf("failed to parse lt from toncenter response: %w", err) - } - - shard, err := strconv.ParseUint(res.BlockRef.Shard, 16, 64) - if err != nil { - return nil, fmt.Errorf("failed to parse shard from toncenter response: %w", err) - } - - block, err := api.LookupBlock(ctx, res.BlockRef.Workchain, int64(shard), res.BlockRef.SeqNo) - if err != nil { - return nil, fmt.Errorf("failed to lookup block from toncenter metadata: %w", err) - } - - tx, err := api.GetTransaction(ctx, block, srcAddr, lt) - if err != nil { - return nil, fmt.Errorf("failed to fetch transaction from toncenter metadata: %w", err) - } - - if !equalHash(tx.Hash, txHash) { - return nil, errors.New("toncenter metadata lookup returned a different transaction hash") - } - - return tx, nil -} - -func (c *client) findTx(ctx context.Context, api ton.APIClientWrapped, srcAddr *address.Address, txHashStr string, txHash []byte) (*tlb.Transaction, error) { - block, err := api.GetMasterchainInfo(ctx) - if err != nil { - return nil, fmt.Errorf("get masterchain info: %w", err) - } - account, err := api.GetAccount(ctx, block, srcAddr) - if err != nil { - return nil, fmt.Errorf("get account: %w", err) - } - - // Start from the latest transaction - maxLT := account.LastTxLT - maxHash := account.LastTxHash - for range c.maxPages { - txs, err := api.ListTransactions(ctx, srcAddr, c.pageSize, maxLT, maxHash) - if err != nil { - return nil, fmt.Errorf("get transaction: %w", err) - } - if len(txs) == 0 { - return nil, errors.New("transaction not found in searched range. Try increasing --page-size and --max-pages") - } - for _, tx := range txs { - if equalHash(tx.Hash, txHash) { - return tx, nil - } - } - // Move to the previous page - last := txs[len(txs)-1] - maxLT = last.PrevTxLT - maxHash = last.PrevTxHash - } - tx, err := c.findTxByToncenterMetadata(ctx, api, txHashStr, txHash, srcAddr) - if err == nil { - return tx, nil - } - - return nil, fmt.Errorf("transaction not found in searched range. Try increasing --page-size and --max-pages (fallback failed: %w)", err) -} - -func equalHash(a, b []byte) bool { - if len(a) != len(b) { - return false - } - for i := range a { - if a[i] != b[i] { - return false - } - } - return true -} - -func connect(ctx context.Context, net string) (*ton.APIClient, error) { - pool := liteclient.NewConnectionPool() - switch net { - case "mainnet": - configURL := "https://ton-blockchain.github.io/global.config.json" - err := pool.AddConnectionsFromConfigUrl(ctx, configURL) - if err != nil { - return nil, fmt.Errorf("failed to add connections from config url: %w", err) - } - case "testnet": - configURL := "https://ton.org/testnet-global.config.json" - err := pool.AddConnectionsFromConfigUrl(ctx, configURL) - if err != nil { - return nil, fmt.Errorf("failed to add connections from config url: %w", err) - } - case "mylocalton": - // Find running mylocalton container - containerID, err := findMylocaltonContainer(ctx) - if err != nil { - return nil, fmt.Errorf("failed to find mylocalton container: %w", err) - } - - // Inspect the container to get port mappings - inspect, err := inspectContainer(ctx, containerID) - if err != nil { - return nil, fmt.Errorf("failed to inspect container %s: %w", containerID, err) - } - - // Get the external port mapping for internal port 8000 (config server) - configPort, err := getPortMapping(inspect, "8000") - if err != nil { - return nil, fmt.Errorf("failed to get port mapping for config server: %w", err) - } - - // Fetch the config from the mapped port - configURL := fmt.Sprintf("http://127.0.0.1:%s/localhost.global.config.json", configPort) - config, err := liteclient.GetConfigFromUrl(ctx, configURL) - if err != nil { - return nil, fmt.Errorf("failed to get config from url: %w", err) - } - - // Get the liteserver port mapping - liteserverConfig := config.Liteservers[0] - liteserverPort := strconv.Itoa(liteserverConfig.Port) - externalLiteserverPort, err := getPortMapping(inspect, liteserverPort) - if err != nil { - return nil, fmt.Errorf("failed to get port mapping for liteserver: %w", err) - } - - // Connect to the liteserver using the external port - connectionString := "127.0.0.1:" + externalLiteserverPort - err = pool.AddConnection(ctx, connectionString, liteserverConfig.ID.Key) - if err != nil { - return nil, fmt.Errorf("failed to add localton connection: %w", err) - } - default: - configURL := net - err := pool.AddConnectionsFromConfigUrl(ctx, configURL) - if err != nil { - return nil, fmt.Errorf("failed to add connections from config url: %w", err) - } - } - return ton.NewAPIClient(pool, ton.ProofCheckPolicyFast), nil -} diff --git a/pkg/ton/codec/debug/explorer/format.go b/pkg/ton/codec/debug/explorer/format.go new file mode 100644 index 000000000..7ffc3f74b --- /dev/null +++ b/pkg/ton/codec/debug/explorer/format.go @@ -0,0 +1,33 @@ +package explorer + +import ( + "errors" + "fmt" +) + +type Format int + +const ( + FormatTree Format = iota + FormatSequenceURL + FormatSequenceRaw +) + +func parseFormat(visualization string, format string) (Format, error) { + switch visualization { + case "tree": + if format != "" { + return Format(0), errors.New("format option is not applicable for tree visualization") + } + return FormatTree, nil + case "sequence": + switch format { + case "", "url": + return FormatSequenceURL, nil + case "raw": + return FormatSequenceRaw, nil + } + return Format(0), fmt.Errorf("invalid sequence format: %s", format) + } + return Format(0), fmt.Errorf("invalid visualization format: %s", visualization) +} diff --git a/pkg/ton/codec/debug/explorer/network_connect.go b/pkg/ton/codec/debug/explorer/network_connect.go new file mode 100644 index 000000000..31705dced --- /dev/null +++ b/pkg/ton/codec/debug/explorer/network_connect.go @@ -0,0 +1,65 @@ +package explorer + +import ( + "context" + "fmt" + "strconv" + + "github.com/xssnick/tonutils-go/liteclient" + "github.com/xssnick/tonutils-go/ton" +) + +func connect(ctx context.Context, net string) (*ton.APIClient, error) { + pool := liteclient.NewConnectionPool() + switch net { + case "mainnet": + configURL := "https://ton-blockchain.github.io/global.config.json" + if err := pool.AddConnectionsFromConfigUrl(ctx, configURL); err != nil { + return nil, fmt.Errorf("failed to add connections from config url: %w", err) + } + case "testnet": + configURL := "https://ton.org/testnet-global.config.json" + if err := pool.AddConnectionsFromConfigUrl(ctx, configURL); err != nil { + return nil, fmt.Errorf("failed to add connections from config url: %w", err) + } + case "mylocalton": + containerID, err := findMylocaltonContainer(ctx) + if err != nil { + return nil, fmt.Errorf("failed to find mylocalton container: %w", err) + } + + inspect, err := inspectContainer(ctx, containerID) + if err != nil { + return nil, fmt.Errorf("failed to inspect container %s: %w", containerID, err) + } + + configPort, err := getPortMapping(inspect, "8000") + if err != nil { + return nil, fmt.Errorf("failed to get port mapping for config server: %w", err) + } + + configURL := fmt.Sprintf("http://127.0.0.1:%s/localhost.global.config.json", configPort) + config, err := liteclient.GetConfigFromUrl(ctx, configURL) + if err != nil { + return nil, fmt.Errorf("failed to get config from url: %w", err) + } + + liteserverConfig := config.Liteservers[0] + liteserverPort := strconv.Itoa(liteserverConfig.Port) + externalLiteserverPort, err := getPortMapping(inspect, liteserverPort) + if err != nil { + return nil, fmt.Errorf("failed to get port mapping for liteserver: %w", err) + } + + connectionString := "127.0.0.1:" + externalLiteserverPort + if err = pool.AddConnection(ctx, connectionString, liteserverConfig.ID.Key); err != nil { + return nil, fmt.Errorf("failed to add localton connection: %w", err) + } + default: + if err := pool.AddConnectionsFromConfigUrl(ctx, net); err != nil { + return nil, fmt.Errorf("failed to add connections from config url: %w", err) + } + } + + return ton.NewAPIClient(pool, ton.ProofCheckPolicyFast), nil +} diff --git a/pkg/ton/codec/debug/explorer/network_mylocalton.go b/pkg/ton/codec/debug/explorer/network_mylocalton.go new file mode 100644 index 000000000..4d820e94f --- /dev/null +++ b/pkg/ton/codec/debug/explorer/network_mylocalton.go @@ -0,0 +1,99 @@ +package explorer + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os/exec" + "strings" +) + +// ContainerInspect represents the structure returned by docker inspect +// for the fields needed by mylocalton discovery. +type ContainerInspect struct { + ID string `json:"Id"` + State struct { + Running bool `json:"Running"` + } `json:"State"` + Config struct { + Image string `json:"Image"` + } `json:"Config"` + NetworkSettings struct { + Ports map[string][]struct { + HostIP string `json:"HostIp"` + HostPort string `json:"HostPort"` + } `json:"Ports"` + } `json:"NetworkSettings"` +} + +// findMylocaltonContainer finds a running mylocalton container and returns its ID. +func findMylocaltonContainer(ctx context.Context) (string, error) { + cmd := exec.CommandContext(ctx, "docker", "ps", "--format", "{{.ID}}\t{{.Image}}", "--filter", "status=running") + output, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("failed to list docker containers: %w", err) + } + + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + for _, line := range lines { + if line == "" { + continue + } + parts := strings.Split(line, "\t") + if len(parts) != 2 { + continue + } + containerID := parts[0] + image := parts[1] + + if strings.Contains(image, "mylocalton-docker") && !strings.Contains(image, "mylocalton-docker-explorer") { + return containerID, nil + } + } + + return "", errors.New("no running mylocalton container found") +} + +// inspectContainer runs docker inspect on the given container ID. +func inspectContainer(ctx context.Context, containerID string) (*ContainerInspect, error) { + cmd := exec.CommandContext(ctx, "docker", "inspect", containerID) + output, err := cmd.CombinedOutput() + if err != nil { + if strings.Contains(string(output), "No such object") || strings.Contains(string(output), "No such container") { + return nil, fmt.Errorf("container %s does not exist", containerID) + } + return nil, fmt.Errorf("docker inspect failed: %w\nOutput: %s", err, string(output)) + } + + var inspects []ContainerInspect + if err := json.Unmarshal(output, &inspects); err != nil { + return nil, fmt.Errorf("failed to parse docker inspect output: %w", err) + } + if len(inspects) == 0 { + return nil, fmt.Errorf("container %s not found", containerID) + } + + inspect := &inspects[0] + if !inspect.State.Running { + return nil, fmt.Errorf("container %s exists but is not running", containerID) + } + + return inspect, nil +} + +// getPortMapping extracts the host port that maps to a given container port. +func getPortMapping(inspect *ContainerInspect, containerPort string) (string, error) { + portKey := containerPort + "/tcp" + ports, exists := inspect.NetworkSettings.Ports[portKey] + if !exists || len(ports) == 0 { + return "", fmt.Errorf("no port mapping found for container port %s", containerPort) + } + + hostPort := ports[0].HostPort + if hostPort == "" { + return "", fmt.Errorf("empty host port mapping for container port %s", containerPort) + } + + return hostPort, nil +} diff --git a/pkg/ton/codec/debug/explorer/tx_lookup.go b/pkg/ton/codec/debug/explorer/tx_lookup.go new file mode 100644 index 000000000..9e3ddcb92 --- /dev/null +++ b/pkg/ton/codec/debug/explorer/tx_lookup.go @@ -0,0 +1,277 @@ +package explorer + +import ( + "context" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/xssnick/tonutils-go/address" + "github.com/xssnick/tonutils-go/tlb" + "github.com/xssnick/tonutils-go/ton" +) + +type toncenterTxResult struct { + Account string `json:"account"` + LT string `json:"lt"` + BlockRef struct { + Workchain int32 `json:"workchain"` + Shard string `json:"shard"` + SeqNo uint32 `json:"seqno"` + } `json:"block_ref"` +} + +type toncenterAPIResponse struct { + Transactions []toncenterTxResult `json:"transactions"` +} + +type toncenterTraceResponse struct { + Traces []struct { + Trace struct { + TxHash string `json:"tx_hash"` + } `json:"trace"` + TransactionsOrder []string `json:"transactions_order"` + } `json:"traces"` +} + +func (c *client) supportsToncenter() bool { + return c.net == "mainnet" || c.net == "testnet" +} + +func decodeTxHash(txHash string) ([]byte, error) { + if after, ok := strings.CutPrefix(txHash, "0x"); ok { + txHash = after + } + + if raw, err := hex.DecodeString(txHash); err == nil { + return raw, nil + } + if raw, err := base64.StdEncoding.DecodeString(txHash); err == nil { + return raw, nil + } + if raw, err := base64.URLEncoding.DecodeString(txHash); err == nil { + return raw, nil + } + if raw, err := base64.RawURLEncoding.DecodeString(txHash); err == nil { + return raw, nil + } + + return nil, fmt.Errorf("unsupported tx hash format: %s", txHash) +} + +func (c *client) getTraceRootTxHash(ctx context.Context, txHashStr string) (string, error) { + u, err := c.tonCenterTraceURL() + if err != nil { + return "", err + } + + q := u.Query() + q.Set("tx_hash", txHashStr) + u.RawQuery = q.Encode() + + httpClient := &http.Client{Timeout: 30 * time.Second} + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return "", fmt.Errorf("failed to create trace request: %w", err) + } + + resp, err := httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("failed to fetch trace from toncenter: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("unexpected status code from trace endpoint: %d", resp.StatusCode) + } + + var traceResp toncenterTraceResponse + if err = json.NewDecoder(resp.Body).Decode(&traceResp); err != nil { + return "", fmt.Errorf("failed to decode trace response: %w", err) + } + + if len(traceResp.Traces) == 0 { + return "", errors.New("no trace found for transaction") + } + + trace := traceResp.Traces[0] + if len(trace.TransactionsOrder) > 0 && trace.TransactionsOrder[0] != "" { + return trace.TransactionsOrder[0], nil + } + if trace.Trace.TxHash != "" { + return trace.Trace.TxHash, nil + } + + return "", errors.New("trace root hash missing in trace response") +} + +func (c *client) GetSenderAddressFromTxHash(ctx context.Context, txHashStr string) (*address.Address, error) { + res, err := c.getToncenterTxByHash(ctx, txHashStr) + if err != nil { + return nil, err + } + + addr, err := address.ParseRawAddr(res.Account) + if err != nil { + return nil, fmt.Errorf("failed to parse source address from toncenter response: %w", err) + } + return addr, nil +} + +func (c *client) getToncenterTxByHash(ctx context.Context, txHashStr string) (*toncenterTxResult, error) { + u, err := c.tonCenterTransactionsURL() + if err != nil { + return nil, err + } + + q := u.Query() + q.Set("hash", txHashStr) + u.RawQuery = q.Encode() + + httpClient := &http.Client{Timeout: 30 * time.Second} + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch transaction info from toncenter: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code from toncenter: %d", resp.StatusCode) + } + + var respData toncenterAPIResponse + if err = json.NewDecoder(resp.Body).Decode(&respData); err != nil { + return nil, fmt.Errorf("failed to decode toncenter response: %w", err) + } + if len(respData.Transactions) != 1 { + return nil, errors.New("transaction not found in toncenter response") + } + + return &respData.Transactions[0], nil +} + +func (c *client) findTxByToncenterMetadata(ctx context.Context, api ton.APIClientWrapped, txHashStr string, txHash []byte, srcAddr *address.Address) (*tlb.Transaction, error) { + res, err := c.getToncenterTxByHash(ctx, txHashStr) + if err != nil { + return nil, err + } + + lt, err := strconv.ParseUint(res.LT, 10, 64) + if err != nil { + return nil, fmt.Errorf("failed to parse lt from toncenter response: %w", err) + } + + shard, err := strconv.ParseUint(res.BlockRef.Shard, 16, 64) + if err != nil { + return nil, fmt.Errorf("failed to parse shard from toncenter response: %w", err) + } + + block, err := api.LookupBlock(ctx, res.BlockRef.Workchain, int64(shard), res.BlockRef.SeqNo) + if err != nil { + return nil, fmt.Errorf("failed to lookup block from toncenter metadata: %w", err) + } + + tx, err := api.GetTransaction(ctx, block, srcAddr, lt) + if err != nil { + return nil, fmt.Errorf("failed to fetch transaction from toncenter metadata: %w", err) + } + + if !equalHash(tx.Hash, txHash) { + return nil, errors.New("toncenter metadata lookup returned a different transaction hash") + } + + return tx, nil +} + +func (c *client) findTx(ctx context.Context, api ton.APIClientWrapped, srcAddr *address.Address, txHashStr string, txHash []byte) (*tlb.Transaction, error) { + block, err := api.GetMasterchainInfo(ctx) + if err != nil { + return nil, fmt.Errorf("get masterchain info: %w", err) + } + account, err := api.GetAccount(ctx, block, srcAddr) + if err != nil { + return nil, fmt.Errorf("get account: %w", err) + } + + maxLT := account.LastTxLT + maxHash := account.LastTxHash + for range c.maxPages { + txs, listErr := api.ListTransactions(ctx, srcAddr, c.pageSize, maxLT, maxHash) + if listErr != nil { + return nil, fmt.Errorf("get transaction: %w", listErr) + } + if len(txs) == 0 { + return nil, errors.New("transaction not found in searched range. Try increasing --page-size and --max-pages") + } + for _, tx := range txs { + if equalHash(tx.Hash, txHash) { + return tx, nil + } + } + + last := txs[len(txs)-1] + maxLT = last.PrevTxLT + maxHash = last.PrevTxHash + } + + if !c.supportsToncenter() { + return nil, errors.New("transaction not found in searched range and toncenter fallback is unavailable for this network") + } + + fallbackTx, fallbackErr := c.findTxByToncenterMetadata(ctx, api, txHashStr, txHash, srcAddr) + if fallbackErr == nil { + return fallbackTx, nil + } + return nil, fmt.Errorf("transaction not found in searched range. Try increasing --page-size and --max-pages (fallback failed: %w)", fallbackErr) +} + +func equalHash(a, b []byte) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func (c *client) tonCenterTraceURL() (*url.URL, error) { + return c.tonCenterURL("traces") +} + +func (c *client) tonCenterTransactionsURL() (*url.URL, error) { + return c.tonCenterURL("transactions") +} + +func (c *client) tonCenterURL(path string) (*url.URL, error) { + var baseURL string + switch c.net { + case "mainnet": + baseURL = "https://toncenter.com/api/v3/" + case "testnet": + baseURL = "https://testnet.toncenter.com/api/v3/" + default: + return nil, fmt.Errorf("unsupported network for toncenter lookup: %s", c.net) + } + + u, err := url.Parse(baseURL) + if err != nil { + return nil, fmt.Errorf("invalid base URL: %w", err) + } + u.Path = strings.TrimSuffix(u.Path, "/") + "/" + path + return u, nil +} diff --git a/pkg/ton/codec/debug/visualizations/sequence/sanitizer.go b/pkg/ton/codec/debug/visualizations/sequence/sanitizer.go index 3aceaf90c..4cb8d4a02 100644 --- a/pkg/ton/codec/debug/visualizations/sequence/sanitizer.go +++ b/pkg/ton/codec/debug/visualizations/sequence/sanitizer.go @@ -5,6 +5,43 @@ import ( "unicode/utf8" ) +func sanitizeMermaidIdentifier(s string) string { + if s == "" { + return "actor" + } + + var b strings.Builder + b.Grow(len(s) + 6) + + for i, r := range s { + isAlpha := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') + isDigit := r >= '0' && r <= '9' + + if i == 0 { + if isAlpha || r == '_' { + b.WriteRune(r) + continue + } + if isDigit { + b.WriteString("a_") + b.WriteRune(r) + continue + } + b.WriteString("a_") + b.WriteByte('_') + continue + } + + if isAlpha || isDigit || r == '_' { + b.WriteRune(r) + } else { + b.WriteByte('_') + } + } + + return b.String() +} + func sanitizeString(s string) string { var b strings.Builder for i, line := range strings.Split(s, "\n") { diff --git a/pkg/ton/codec/debug/visualizations/sequence/sanitizer_test.go b/pkg/ton/codec/debug/visualizations/sequence/sanitizer_test.go index ff8159ccf..6e85808f7 100644 --- a/pkg/ton/codec/debug/visualizations/sequence/sanitizer_test.go +++ b/pkg/ton/codec/debug/visualizations/sequence/sanitizer_test.go @@ -135,3 +135,39 @@ func TestWrap(t *testing.T) { }) } } + +func TestSanitizeMermaidIdentifier(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "masterchain raw address", + input: "-1_e69571e7b9f58edfebefa297e547f36920532289dbe9ff1b76d107fcbac30104", + expected: "a__1_e69571e7b9f58edfebefa297e547f36920532289dbe9ff1b76d107fcbac30104", + }, + { + name: "colon separated raw address", + input: "-1:e69571e7b9f58edfebefa297e547f36920532289dbe9ff1b76d107fcbac30104", + expected: "a__1_e69571e7b9f58edfebefa297e547f36920532289dbe9ff1b76d107fcbac30104", + }, + { + name: "starts with digit", + input: "0_abc", + expected: "a_0_abc", + }, + { + name: "already valid", + input: "abc_123", + expected: "abc_123", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := sanitizeMermaidIdentifier(tt.input) + assert.Equalf(t, tt.expected, result, "Failed test: %s", tt.name) + }) + } +} diff --git a/pkg/ton/codec/debug/visualizations/sequence/sequence_diagram.go b/pkg/ton/codec/debug/visualizations/sequence/sequence_diagram.go index 280756c20..6a6c5a9af 100644 --- a/pkg/ton/codec/debug/visualizations/sequence/sequence_diagram.go +++ b/pkg/ton/codec/debug/visualizations/sequence/sequence_diagram.go @@ -120,7 +120,7 @@ func (v *visualization) actorFromAddr(addr *address.Address) *sequence.Actor { var actor *sequence.Actor var ok bool name := v.describeAddr(addr) - id := strings.ReplaceAll(addr.StringRaw(), ":", "_") + id := sanitizeMermaidIdentifier(addr.StringRaw()) if actor, ok = v.ActiveActors[id]; !ok { actor = v.Diagram.AddActor(id, name, sequence.ActorParticipant) v.ActiveActors[id] = actor From e3e5ba080ce8bf9ec996d83e9781794599425a42 Mon Sep 17 00:00:00 2001 From: Patricio Tourne Passarino Date: Thu, 26 Feb 2026 15:59:33 -0300 Subject: [PATCH 5/7] ref: extract getSenderAddress --- pkg/ton/codec/debug/explorer/explorer.go | 64 +++++++++++++----------- 1 file changed, 34 insertions(+), 30 deletions(-) diff --git a/pkg/ton/codec/debug/explorer/explorer.go b/pkg/ton/codec/debug/explorer/explorer.go index 71d7f17e0..704e5c1b6 100644 --- a/pkg/ton/codec/debug/explorer/explorer.go +++ b/pkg/ton/codec/debug/explorer/explorer.go @@ -157,36 +157,9 @@ func (c *client) PrintTrace(ctx context.Context, txHashStr string, srcAddrStr st } } - var senderAddr *address.Address - var err error - if srcAddrStr == "" { - if !c.supportsToncenter() { - return fmt.Errorf("source address is required for network %s when toncenter metadata is unavailable", c.net) - } - c.lggr.Debug("source address not provided, attempting to fetch from toncenter by hash...") - senderAddr, err = c.GetSenderAddressFromTxHash(ctx, effectiveTxHash) - if err != nil { - return fmt.Errorf("failed to get sender address from tx hash: %w", err) - } - c.lggr.Debug("source address found:", senderAddr.String()) - } else if effectiveTxHash != txHashStr { - if c.supportsToncenter() { - senderAddr, err = c.GetSenderAddressFromTxHash(ctx, effectiveTxHash) - if err != nil { - return fmt.Errorf("failed to get root sender address from tx hash: %w", err) - } - c.lggr.Debug("overriding provided source address with trace root account", senderAddr.String()) - } else { - senderAddr, err = address.ParseAddr(srcAddrStr) - if err != nil { - return fmt.Errorf("failed to parse transaction address: %w", err) - } - } - } else { - senderAddr, err = address.ParseAddr(srcAddrStr) - if err != nil { - return fmt.Errorf("failed to parse transaction address: %w", err) - } + senderAddr, err := resolveSenderAddress(ctx, c, srcAddrStr, effectiveTxHash, txHashStr) + if err != nil { + return fmt.Errorf("failed to resolve sender address: %w", err) } decodedTxHash, err := decodeTxHash(effectiveTxHash) if err != nil { @@ -242,6 +215,37 @@ func (c *client) PrintTrace(ctx context.Context, txHashStr string, srcAddrStr st return nil } +func resolveSenderAddress(ctx context.Context, c *client, srcAddrStr string, effectiveTxHash string, txHashStr string) (*address.Address, error) { + var err error + if srcAddrStr == "" { + if !c.supportsToncenter() { + return nil, fmt.Errorf("source address is required for network %s when toncenter metadata is unavailable", c.net) + } + c.lggr.Debug("source address not provided, attempting to fetch from toncenter by hash...") + senderAddr, err := c.GetSenderAddressFromTxHash(ctx, effectiveTxHash) + if err != nil { + return nil, fmt.Errorf("failed to get sender address from tx hash: %w", err) + } + c.lggr.Debug("source address found:", senderAddr.String()) + return senderAddr, nil + } + if effectiveTxHash != txHashStr && c.supportsToncenter() { + senderAddr, err := c.GetSenderAddressFromTxHash(ctx, effectiveTxHash) + if err != nil { + return nil, fmt.Errorf("failed to get root sender address from tx hash: %w", err) + } + c.lggr.Debug("overriding provided source address with trace root account", senderAddr.String()) + return senderAddr, nil + } + + senderAddr, err := address.ParseAddr(srcAddrStr) + if err != nil { + return nil, fmt.Errorf("failed to parse transaction address: %w", err) + } + + return senderAddr, nil +} + func (c *client) queryActors(ctx context.Context, api ton.APIClientWrapped, message *tracetracking.ReceivedMessage, knownActors map[string]debug.TypeAndVersion) error { visited := make(map[string]bool) block, err := api.CurrentMasterchainInfo(ctx) From 67b1b647c04fa349a12eb9cf858348f546dd00b1 Mon Sep 17 00:00:00 2001 From: Patricio Tourne Passarino Date: Thu, 26 Feb 2026 16:02:37 -0300 Subject: [PATCH 6/7] fix: lint --- pkg/ton/codec/debug/explorer/explorer.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/ton/codec/debug/explorer/explorer.go b/pkg/ton/codec/debug/explorer/explorer.go index 704e5c1b6..d9cc3f101 100644 --- a/pkg/ton/codec/debug/explorer/explorer.go +++ b/pkg/ton/codec/debug/explorer/explorer.go @@ -216,7 +216,6 @@ func (c *client) PrintTrace(ctx context.Context, txHashStr string, srcAddrStr st } func resolveSenderAddress(ctx context.Context, c *client, srcAddrStr string, effectiveTxHash string, txHashStr string) (*address.Address, error) { - var err error if srcAddrStr == "" { if !c.supportsToncenter() { return nil, fmt.Errorf("source address is required for network %s when toncenter metadata is unavailable", c.net) From 126b5779e6356841c01f95045f2ff3da6b7b54ed Mon Sep 17 00:00:00 2001 From: Patricio Tourne Passarino Date: Thu, 26 Feb 2026 16:05:40 -0300 Subject: [PATCH 7/7] fix: overflow --- pkg/ton/codec/debug/explorer/tx_lookup.go | 38 +++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/pkg/ton/codec/debug/explorer/tx_lookup.go b/pkg/ton/codec/debug/explorer/tx_lookup.go index 9e3ddcb92..ade2dcb03 100644 --- a/pkg/ton/codec/debug/explorer/tx_lookup.go +++ b/pkg/ton/codec/debug/explorer/tx_lookup.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "math/big" "net/http" "net/url" "strconv" @@ -66,6 +67,39 @@ func decodeTxHash(txHash string) ([]byte, error) { return nil, fmt.Errorf("unsupported tx hash format: %s", txHash) } +func parseShardID(shardHex string) (int64, error) { + if after, ok := strings.CutPrefix(shardHex, "0x"); ok { + shardHex = after + } + + parsed := new(big.Int) + if _, ok := parsed.SetString(shardHex, 16); !ok { + return 0, fmt.Errorf("invalid shard id: %s", shardHex) + } + + if parsed.Sign() < 0 { + if !parsed.IsInt64() { + return 0, fmt.Errorf("shard id out of int64 range: %s", shardHex) + } + return parsed.Int64(), nil + } + + if parsed.BitLen() > 64 { + return 0, fmt.Errorf("shard id out of 64-bit range: %s", shardHex) + } + + if parsed.Bit(63) == 1 { + twoTo64 := new(big.Int).Lsh(big.NewInt(1), 64) + parsed.Sub(parsed, twoTo64) + } + + if !parsed.IsInt64() { + return 0, fmt.Errorf("shard id out of int64 range: %s", shardHex) + } + + return parsed.Int64(), nil +} + func (c *client) getTraceRootTxHash(ctx context.Context, txHashStr string) (string, error) { u, err := c.tonCenterTraceURL() if err != nil { @@ -173,12 +207,12 @@ func (c *client) findTxByToncenterMetadata(ctx context.Context, api ton.APIClien return nil, fmt.Errorf("failed to parse lt from toncenter response: %w", err) } - shard, err := strconv.ParseUint(res.BlockRef.Shard, 16, 64) + shard, err := parseShardID(res.BlockRef.Shard) if err != nil { return nil, fmt.Errorf("failed to parse shard from toncenter response: %w", err) } - block, err := api.LookupBlock(ctx, res.BlockRef.Workchain, int64(shard), res.BlockRef.SeqNo) + block, err := api.LookupBlock(ctx, res.BlockRef.Workchain, shard, res.BlockRef.SeqNo) if err != nil { return nil, fmt.Errorf("failed to lookup block from toncenter metadata: %w", err) }