diff --git a/deployment/ccip/sequence/deploy_ccip.go b/deployment/ccip/sequence/deploy_ccip.go index fd87c48e8..164b9da31 100644 --- a/deployment/ccip/sequence/deploy_ccip.go +++ b/deployment/ccip/sequence/deploy_ccip.go @@ -235,7 +235,7 @@ func deployCCIPSequence(b operations.Bundle, dp *dep.DependencyProvider, in Depl PendingOwner: address.NewAddressNone(), }, AuthorizedCaller: &routerAddress, - Behavior: receiver.Accept, + Behavior: receiver.BehaviorAccept, } outputAddr, err = utils.InvokeDeployContractOperation(b, dp, in.ChainSelector, tonCompiledContracts[state.TonReceiver], receiverStorage, nil, in.CCIPConfig.ReceiverParams.Coin, in.CCIPConfig.ReceiverParams.ContractsSemver) diff --git a/docs/.misc/dev-guides/explorer/architecture.md b/docs/.misc/dev-guides/explorer/architecture.md new file mode 100644 index 000000000..09c4c65fd --- /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 trace CLI contract is: + +- `explorer trace ` +- `explorer trace
` +- `explorer trace ` (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..d211dca3c 100644 --- a/docs/.misc/dev-guides/explorer/usage.md +++ b/docs/.misc/dev-guides/explorer/usage.md @@ -2,13 +2,16 @@ 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: +Four ways to run: -1. **URL**: `./explorer ` -2. **Hash + Address**: `./explorer
` -3. **Hash only**: `./explorer ` (testnet/mainnet only) +1. **URL**: `./explorer trace ` +2. **Hash + Address**: `./explorer trace
` +3. **Hash only**: `./explorer trace ` (testnet/mainnet only unless sender address is provided separately) +4. **Getter call**: `./explorer get
[getter_name] [args...]` ## Run with Nix @@ -17,7 +20,7 @@ The `explorer` binary is packaged with `chainlink-ton-extras` pkg bundle. We can start a dev shell including specific pkg contents and execute a bash cmd: ```bash -nix shell .#chainlink-ton-extras -c explorer https://testnet.tonscan.org/tx/ +nix shell .#chainlink-ton-extras -c explorer trace https://testnet.tonscan.org/tx/ ``` ## Build @@ -31,16 +34,89 @@ go build ```bash # URL (recommended) -./explorer https://testnet.tonscan.org/tx/ -./explorer http://localhost:8080/transaction?account=&hash= +./explorer trace https://testnet.tonscan.org/tx/ +./explorer trace http://localhost:8080/transaction?account=&hash= # Hash + address -./explorer
[--net testnet|mainnet|mylocalton|http://custom-domain/global.config.json] +./explorer trace
[--net testnet|mainnet|mylocalton|http://custom-domain/global.config.json] + +# Hash only (auto-resolves address via toncenter on testnet/mainnet) +./explorer trace [--net testnet|mainnet|mylocalton|http://custom-domain/global.config.json] + +# Getter call +./explorer get
[getter_name] [args...] [--arg name=value] [--net testnet|mainnet|mylocalton|http://custom-domain/global.config.json] [--contract-type ] + +# Example +./explorer get EQA-CUZI_USus4w0_Erf-wTj5uhaAR7XldEimU0w0WAJGGod dynamicConfig +``` + +## Getter command + +`explorer get` supports no-args and argument-based getters and prints decoded JSON output. + +When `getter_name` is omitted in an interactive terminal, explorer opens a numbered selector prompt (`0` to cancel). +When a selected getter requires arguments and values are missing, explorer prompts for those values. + +The command tries to infer the contract type by calling `typeAndVersion` on the target address. +If inference is unavailable/fails, pass `--contract-type` explicitly. + +Examples: -# Hash only (auto-resolves address) -./explorer [--net testnet|mainnet|mylocalton|http://custom-domain/global.config.json] +```bash +# auto-detect contract type +./explorer get
owner + +# explicit contract type +./explorer get
owner --contract-type link.chain.ton.ccip.OnRamp + +# positional args +./explorer get onRamp 16015286601757825753 + +# named args +./explorer get getRoleMember --arg role=1 --arg index=0 ``` +## Autocomplete setup + +The explorer uses Cobra shell completion, including dynamic getter completion for: + +`explorer get
` + +### zsh (current shell only) + +```bash +source <(./explorer completion zsh) +``` + +### zsh (persistent) + +```bash +mkdir -p ~/.zfunc +./explorer completion zsh > ~/.zfunc/_explorer +``` + +Add this to `~/.zshrc`: + +```bash +fpath=(~/.zfunc $fpath) +autoload -Uz compinit +compinit +``` + +Reload: + +```bash +source ~/.zshrc +``` + +If you run the local binary directly from this repository, add an alias in `~/.zshrc`: + +```bash +alias explorer="/Users/patricio.passarino/Code/ton/chainlink-ton-explorer/explorer" +``` + +Then you can tab-complete getter names for a contract address. + ## Networks Choose the network with `-n`/`--net` flag: @@ -70,8 +146,12 @@ Display message trace as a tree structure with `--visualization tree`. ```bash --verbose # Show debugging information --page-size 10 --max-pages 10 # Control transaction search pagination +--contract-type # (get command) optional contract type override +--arg name=value # (get command) named getter argument (repeatable) ``` +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/bindings/getters.go b/pkg/bindings/getters.go index 33b1de9ed..1c1731c8f 100644 --- a/pkg/bindings/getters.go +++ b/pkg/bindings/getters.go @@ -28,6 +28,8 @@ var TypeToGetterMap = map[string]GetterMap{ "rmn_pendingOwner": router.GetRMNPendingOwner, "onRamp": router.GetOnRamp, "destChainSelectors": router.GetDestChainSelectors, + "verifyNotCursed": router.GetVerifyNotCursed, + "cursedSubjects": router.GetCursedSubjects, }, TypeOnRamp: { "owner": onramp.GetOwner, @@ -46,17 +48,22 @@ var TypeToGetterMap = map[string]GetterMap{ "sourceChainSelectors": offramp.GetSourceChainSelectors, }, TypeFeeQuoter: { - "owner": feequoter.GetOwner, - "pendingOwner": feequoter.GetPendingOwner, - "destChainConfig": feequoter.GetDestChainConfig, - "destinationChainGasPrice": feequoter.GetDestinationChainGasPrice, - "tokenPrice": feequoter.GetTokenPrice, - "staticConfig": feequoter.GetStaticConfig, - "destChainSelectors": feequoter.GetDestChainSelectors, + "owner": feequoter.GetOwner, + "pendingOwner": feequoter.GetPendingOwner, + "destChainConfig": feequoter.GetDestChainConfig, + "destinationChainGasPrice": feequoter.GetDestinationChainGasPrice, + "tokenPrice": feequoter.GetTokenPrice, + "staticConfig": feequoter.GetStaticConfig, + "destChainSelectors": feequoter.GetDestChainSelectors, + "feeTokens": feequoter.GetFeeTokens, + "premiumMultiplierWeiPerEth": feequoter.GetPremiumMultiplierWeiPerEth, + "tokenTransferFeeConfig": feequoter.GetTokenTransferFeeConfig, }, // Common contract getters (applies to all CCIP contracts) "link.chain.ton.ccip.Common": { "typeAndVersion": common.GetTypeAndVersion, + "facilityId": common.GetFacilityId, + "errorCode": common.GetErrorCode, }, // Ownable2Step pattern (inherited by many contracts) TypeOwnable: { diff --git a/pkg/bindings/index.go b/pkg/bindings/index.go index 1eaca63e0..6ffce30e8 100644 --- a/pkg/bindings/index.go +++ b/pkg/bindings/index.go @@ -9,7 +9,10 @@ import ( "github.com/smartcontractkit/chainlink-ton/pkg/bindings/mcms/mcms" "github.com/smartcontractkit/chainlink-ton/pkg/bindings/mcms/timelock" "github.com/smartcontractkit/chainlink-ton/pkg/ccip/bindings/ccipsendexecutor" + "github.com/smartcontractkit/chainlink-ton/pkg/ccip/bindings/merkleroot" "github.com/smartcontractkit/chainlink-ton/pkg/ccip/bindings/ownable2step" + "github.com/smartcontractkit/chainlink-ton/pkg/ccip/bindings/receiveexecutor" + "github.com/smartcontractkit/chainlink-ton/pkg/ccip/bindings/receiver" "github.com/smartcontractkit/chainlink-ton/pkg/ton/tvm" "github.com/smartcontractkit/chainlink-ton/pkg/ccip/bindings/feequoter" @@ -40,11 +43,16 @@ const ( TypeTimelock = PkgMCMS + ".Timelock" // CCIP - TypeRouter = PkgCCIP + ".Router" - TypeOnRamp = PkgCCIP + ".OnRamp" - TypeOffRamp = PkgCCIP + ".OffRamp" - TypeFeeQuoter = PkgCCIP + ".FeeQuoter" - TypeSendExecutor = PkgCCIP + ".CCIPSendExecutor" + TypeRouter = PkgCCIP + ".Router" + TypeOnRamp = PkgCCIP + ".OnRamp" + TypeOffRamp = PkgCCIP + ".OffRamp" + TypeFeeQuoter = PkgCCIP + ".FeeQuoter" + TypeSendExecutor = PkgCCIP + ".CCIPSendExecutor" + TypeReceiveExecutor = PkgCCIP + ".ReceiveExecutor" + TypeMerkleRoot = PkgCCIP + ".MerkleRoot" + + // Test CCIP + TypeReceiver = PkgCCIP + ".Receiver" // Jetton TypeJettonWallet = PkgJetton + ".contracts.jetton-wallet" @@ -64,11 +72,16 @@ var Registry = tvm.ContractTLBRegistry{ TypeTimelock: timelock.TLBs, // CCIP contract types - TypeRouter: router.TLBs, - TypeOnRamp: onramp.TLBs, - TypeOffRamp: offramp.TLBs, - TypeFeeQuoter: feequoter.TLBs, - TypeSendExecutor: ccipsendexecutor.TLBs, + TypeRouter: router.TLBs, + TypeOnRamp: onramp.TLBs, + TypeOffRamp: offramp.TLBs, + TypeFeeQuoter: feequoter.TLBs, + TypeSendExecutor: ccipsendexecutor.TLBs, + TypeReceiveExecutor: receiveexecutor.TLBs, + TypeMerkleRoot: merkleroot.TLBs, + + // Test contract types + TypeReceiver: receiver.TLBs, // Jetton contract types TypeJettonWallet: wallet.TLBs, diff --git a/pkg/ccip/bindings/common/common.go b/pkg/ccip/bindings/common/common.go index a184618f9..be937b3f2 100644 --- a/pkg/ccip/bindings/common/common.go +++ b/pkg/ccip/bindings/common/common.go @@ -15,7 +15,9 @@ import ( ) const ( - versionGetter = "typeAndVersion" + versionGetter = "typeAndVersion" + facilityIdGetter = "facilityId" + errorCodeGetter = "errorCode" ) // TVM limits for cell chains, enforced at different stages: diff --git a/pkg/ccip/bindings/common/reader.go b/pkg/ccip/bindings/common/reader.go index f3367e995..a640591dd 100644 --- a/pkg/ccip/bindings/common/reader.go +++ b/pkg/ccip/bindings/common/reader.go @@ -36,3 +36,27 @@ var GetTypeAndVersion = tvm.NewNoArgsGetter(tvm.NoArgsOpts[TypeAndVersion]{ }, nil }), }) + +// GetFacilityId gets the facility ID of the FeeQuoter contract +var GetFacilityId = tvm.NewNoArgsGetter(tvm.NoArgsOpts[uint16]{ + Name: facilityIdGetter, + Decoder: tvm.NewResultDecoder(func(r *ton.ExecutionResult) (uint16, error) { + v, err := r.Int(0) + if err != nil { + return 0, err + } + return uint16(v.Uint64()), nil + }), +}) + +// GetErrorCode gets the contract-specific error code for a given local error code +var GetErrorCode = tvm.Getter[uint16, uint16]{ + Name: errorCodeGetter, + Decoder: tvm.NewResultDecoder(func(r *ton.ExecutionResult) (uint16, error) { + v, err := r.Int(0) + if err != nil { + return 0, err + } + return uint16(v.Uint64()), nil + }), +} diff --git a/pkg/ccip/bindings/feequoter/fee_quoter.go b/pkg/ccip/bindings/feequoter/fee_quoter.go index 5ccbc6739..914931d78 100644 --- a/pkg/ccip/bindings/feequoter/fee_quoter.go +++ b/pkg/ccip/bindings/feequoter/fee_quoter.go @@ -79,11 +79,14 @@ const ( // Registry method names const ( - DestChainsGetter = "destChainSelectors" - tokenPriceGetter = "tokenPrice" - staticConfigGetter = "staticConfig" - destChainConfigGetter = "destChainConfig" - destinationChainGasPriceGetter = "destinationChainGasPrice" + DestChainsGetter = "destChainSelectors" + tokenPriceGetter = "tokenPrice" + staticConfigGetter = "staticConfig" + destChainConfigGetter = "destChainConfig" + destinationChainGasPriceGetter = "destinationChainGasPrice" + FeeTokensGetter = "feeTokens" + premiumMultiplierWeiPerEthGetter = "premiumMultiplierWeiPerEth" + tokenTransferFeeConfigGetter = "tokenTransferFeeConfig" ) type Storage struct { diff --git a/pkg/ccip/bindings/feequoter/reader.go b/pkg/ccip/bindings/feequoter/reader.go index 76f28d33c..a3e786f72 100644 --- a/pkg/ccip/bindings/feequoter/reader.go +++ b/pkg/ccip/bindings/feequoter/reader.go @@ -214,3 +214,85 @@ var GetDestChainSelectors = tvm.NewNoArgsGetter(tvm.NoArgsOpts[[]uint64]{ return lo.Map(selectors, func(x *big.Int, _ int) uint64 { return x.Uint64() }), nil }), }) + +// GetFeeTokens gets the list of fee token addresses +var GetFeeTokens = tvm.NewNoArgsGetter(tvm.NoArgsOpts[[]*address.Address]{ + Name: FeeTokensGetter, + Decoder: tvm.NewResultDecoder(func(r *ton.ExecutionResult) ([]*address.Address, error) { + slices, err := parser.ParseLispTuple[*cell.Slice](r.AsTuple()) + if err != nil { + return nil, err + } + addrs := make([]*address.Address, 0, len(slices)) + for _, s := range slices { + addr, err := s.LoadAddr() + if err != nil { + return nil, err + } + addrs = append(addrs, addr) + } + return addrs, nil + }), +}) + +// GetPremiumMultiplierWeiPerEth gets the premium multiplier wei per eth for a given fee token +var GetPremiumMultiplierWeiPerEth = tvm.Getter[*address.Address, *big.Int]{ + Name: premiumMultiplierWeiPerEthGetter, + Encoder: tvm.NewArgsEncoder(func(addr *address.Address) ([]any, error) { + addrSlice := cell.BeginCell().MustStoreAddr(addr).EndCell().BeginParse() + return []any{addrSlice}, nil + }), + Decoder: tvm.NewResultDecoder(func(r *ton.ExecutionResult) (*big.Int, error) { + return r.Int(0) + }), +} + +// TokenTransferFeeConfigInput is the input type for GetTokenTransferFeeConfig. +type TokenTransferFeeConfigInput struct { + DestChainSelector uint64 + Token *address.Address +} + +// GetTokenTransferFeeConfig gets the token transfer fee config for a given destination chain and token +var GetTokenTransferFeeConfig = tvm.Getter[TokenTransferFeeConfigInput, TokenTransferFeeConfig]{ + Name: tokenTransferFeeConfigGetter, + Encoder: tvm.NewArgsEncoder(func(args TokenTransferFeeConfigInput) ([]any, error) { + addrSlice := cell.BeginCell().MustStoreAddr(args.Token).EndCell().BeginParse() + return []any{args.DestChainSelector, addrSlice}, nil + }), + Decoder: tvm.NewResultDecoder(func(r *ton.ExecutionResult) (TokenTransferFeeConfig, error) { + var c TokenTransferFeeConfig + isEnabledInt, err := r.Int(0) + if err != nil { + return c, err + } + minFeeUsdCents, err := r.Int(1) + if err != nil { + return c, err + } + maxFeeUsdCents, err := r.Int(2) + if err != nil { + return c, err + } + deciBps, err := r.Int(3) + if err != nil { + return c, err + } + destGasOverhead, err := r.Int(4) + if err != nil { + return c, err + } + destBytesOverhead, err := r.Int(5) + if err != nil { + return c, err + } + return TokenTransferFeeConfig{ + IsEnabled: isEnabledInt.Cmp(big.NewInt(-1)) == 0, + MinFeeUsdCents: uint32(minFeeUsdCents.Uint64()), + MaxFeeUsdCents: uint32(maxFeeUsdCents.Uint64()), + DeciBps: uint16(deciBps.Uint64()), + DestGasOverhead: uint32(destGasOverhead.Uint64()), + DestBytesOverhead: uint32(destBytesOverhead.Uint64()), + }, nil + }), +} diff --git a/pkg/ccip/bindings/merkleroot/merkle_root.go b/pkg/ccip/bindings/merkleroot/merkle_root.go index 1cb3ddcce..297e2f172 100644 --- a/pkg/ccip/bindings/merkleroot/merkle_root.go +++ b/pkg/ccip/bindings/merkleroot/merkle_root.go @@ -4,10 +4,28 @@ import ( "math/big" "github.com/xssnick/tonutils-go/address" + "github.com/xssnick/tonutils-go/tlb" + "github.com/smartcontractkit/chainlink-ton/pkg/ccip/bindings/ocr" "github.com/smartcontractkit/chainlink-ton/pkg/ton/tvm" ) +// Validate represents the MerkleRoot_Validate message. +type Validate struct { + _ tlb.Magic `tlb:"#038ede91" json:"-"` //nolint:revive // Ignore opcode tag + Message ocr.Any2TVMRampMessage `tlb:"^"` + PermissionlessExecutionThresholdSeconds uint32 `tlb:"## 32"` + MetadataHash *big.Int `tlb:"## 256"` + GasOverride *tlb.Coins `tlb:"maybe ."` +} + +// MarkState represents the MerkleRoot_MarkState message. +type MarkState struct { + _ tlb.Magic `tlb:"#019f4cd2" json:"-"` //nolint:revive // Ignore opcode tag + SeqNum uint64 `tlb:"## 64"` + State uint8 `tlb:"## 8"` +} + type Storage struct { Root *big.Int `tlb:"## 256"` Owner *address.Address `tlb:"addr"` @@ -18,6 +36,11 @@ type Storage struct { DeliveredMessageCount uint16 `tlb:"## 16"` } +var TLBs = tvm.MustNewTLBMap([]any{ + Validate{}, + MarkState{}, +}).MustWithStorageType(Storage{}) + //go:generate go run golang.org/x/tools/cmd/stringer@v0.38.0 -type=ExitCode type ExitCode tvm.ExitCode diff --git a/pkg/ccip/bindings/offramp/offramp.go b/pkg/ccip/bindings/offramp/offramp.go index 08aa1d375..4e12f43c3 100644 --- a/pkg/ccip/bindings/offramp/offramp.go +++ b/pkg/ccip/bindings/offramp/offramp.go @@ -15,6 +15,18 @@ import ( "github.com/smartcontractkit/chainlink-ton/pkg/ton/tvm" ) +// Topics +const ( + TopicExecutionStateChanged = 0x4C94C360 // CRC32("ExecutionStateChanged") + TopicCommitReportAccepted = 0x27D3BCE8 // CRC32("CommitReportAccepted") + TopicSourceChainSelectorAdded = 0x989AA53E // CRC32("SourceChainSelectorAdded") + TopicSourceChainConfigUpdated = 0x71E9FD30 // CRC32("SourceChainConfigUpdated") + TopicDynamicConfigSet = 0xAD76A933 // CRC32("DynamicConfigSet") + TopicReceiveExecutorInitExecuteBounced = 0x8DC48A3C // CRC32("ReceiveExecutorInitExecuteBounced") + TopicDeployableInitializeBounced = 0x408AA96F // CRC32("DeployableInitializeBounced") + TopicRouteMessageBounced = 0x9C288FEA // CRC32("RouteMessageBounced") +) + // OCR3Config represents the OCR3 configuration stored on-chain type OCR3Config struct { ConfigInfo ConfigInfo `tlb:"."` @@ -166,6 +178,73 @@ type Execute struct { ExecuteReport ocr.ExecuteReport `tlb:"."` } +// ExecuteValidated represents the executeValidated message. +type ExecuteValidated struct { + _ tlb.Magic `tlb:"#c73d5a8a" json:"-"` //nolint:revive // Ignore opcode tag + Message ocr.Any2TVMRampMessage `tlb:"^"` + Root []byte `tlb:"bits 256"` + MetadataHash []byte `tlb:"bits 256"` + GasOverride *tlb.Coins `tlb:"maybe ."` + ExecutionState uint8 `tlb:"## 8"` +} + +// ManuallyExecute represents the manuallyExecute message. +type ManuallyExecute struct { + _ tlb.Magic `tlb:"#a00785cf" json:"-"` //nolint:revive // Ignore opcode tag + QueryID uint64 `tlb:"## 64"` + Report ocr.ExecuteReport `tlb:"."` + GasOverride tlb.Coins `tlb:"."` +} + +// DispatchValidated represents the dispatchValidated message. +type DispatchValidated struct { + _ tlb.Magic `tlb:"#58cfcb02" json:"-"` //nolint:revive // Ignore opcode tag + Message ocr.Any2TVMRampMessage `tlb:"^"` + ExecID []byte `tlb:"bits 192"` + GasOverride *tlb.Coins `tlb:"maybe ."` +} + +// CCIPReceiveConfirm represents the ccipReceiveConfirm message. +type CCIPReceiveConfirm struct { + _ tlb.Magic `tlb:"#28f4166f" json:"-"` //nolint:revive // Ignore opcode tag + ExecID []byte `tlb:"bits 192"` + Receiver *address.Address `tlb:"addr"` +} + +// CCIPReceiveBounced represents the ccipReceiveBounced message. +type CCIPReceiveBounced struct { + _ tlb.Magic `tlb:"#2dcf2a43" json:"-"` //nolint:revive // Ignore opcode tag + ExecID []byte `tlb:"bits 192"` + Receiver *address.Address `tlb:"addr"` +} + +// NotifySuccess represents the notifySuccess message. +type NotifySuccess struct { + _ tlb.Magic `tlb:"#59e56170" json:"-"` //nolint:revive // Ignore opcode tag + Header ocr.RampMessageHeader `tlb:"."` + ExecID []byte `tlb:"bits 192"` + Root *address.Address `tlb:"addr"` +} + +// NotifyFailure represents the notifyFailure message. +type NotifyFailure struct { + _ tlb.Magic `tlb:"#177ebd03" json:"-"` //nolint:revive // Ignore opcode tag + Header ocr.RampMessageHeader `tlb:"."` + ExecID []byte `tlb:"bits 192"` + Root *address.Address `tlb:"addr"` +} + +// CursedSubjects wraps the cursed subject map used by RMN remote. +type CursedSubjects struct { + Data *cell.Dictionary `tlb:"dict 128"` +} + +// UpdateCursedSubjects represents the updateCursedSubjects message. +type UpdateCursedSubjects struct { + _ tlb.Magic `tlb:"#4ca1bcb3" json:"-"` //nolint:revive // Ignore opcode tag + CursedSubjects CursedSubjects `tlb:"."` +} + type SetDynamicConfig struct { _ tlb.Magic `tlb:"#95bc5a5c" json:"-"` //nolint:revive // Ignore opcode tag QueryID uint64 `tlb:"## 64"` @@ -182,11 +261,20 @@ type UpdateDeployables struct { var TLBs = tvm.MustNewTLBMap([]any{ CCIPReceive{}, - SetOCR3Config{}, UpdateSourceChainConfigs{}, Commit{}, Execute{}, + ExecuteValidated{}, + ManuallyExecute{}, + DispatchValidated{}, + UpdateSourceChainConfigs{}, + CCIPReceiveConfirm{}, + CCIPReceiveBounced{}, + NotifyFailure{}, + NotifySuccess{}, + UpdateCursedSubjects{}, SetDynamicConfig{}, + SetOCR3Config{}, UpdateDeployables{}, }).MustWithStorageType(Storage{}) diff --git a/pkg/ccip/bindings/offramp/offramp_test.go b/pkg/ccip/bindings/offramp/offramp_test.go index 1ae5d0760..5af16d824 100644 --- a/pkg/ccip/bindings/offramp/offramp_test.go +++ b/pkg/ccip/bindings/offramp/offramp_test.go @@ -2,6 +2,7 @@ package offramp import ( "encoding/hex" + "hash/crc32" "math/big" "testing" @@ -15,6 +16,30 @@ import ( "github.com/smartcontractkit/chainlink-ton/pkg/ton/tvm" ) +func TestTopicCRC32Values(t *testing.T) { + tests := []struct { + name string + topic string + expected uint32 + }{ + {name: "TopicExecutionStateChanged", topic: "ExecutionStateChanged", expected: TopicExecutionStateChanged}, + {name: "TopicCommitReportAccepted", topic: "CommitReportAccepted", expected: TopicCommitReportAccepted}, + {name: "TopicSourceChainSelectorAdded", topic: "SourceChainSelectorAdded", expected: TopicSourceChainSelectorAdded}, + {name: "TopicSourceChainConfigUpdated", topic: "SourceChainConfigUpdated", expected: TopicSourceChainConfigUpdated}, + {name: "TopicDynamicConfigSet", topic: "DynamicConfigSet", expected: TopicDynamicConfigSet}, + {name: "TopicReceiveExecutorInitExecuteBounced", topic: "ReceiveExecutorInitExecuteBounced", expected: TopicReceiveExecutorInitExecuteBounced}, + {name: "TopicDeployableInitializeBounced", topic: "DeployableInitializeBounced", expected: TopicDeployableInitializeBounced}, + {name: "TopicRouteMessageBounced", topic: "RouteMessageBounced", expected: TopicRouteMessageBounced}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + computed := crc32.ChecksumIEEE([]byte(tt.topic)) + require.Equal(t, tt.expected, computed, "CRC32 mismatch for %s: expected 0x%08X, got 0x%08X", tt.topic, tt.expected, computed) + }) + } +} + func TestCommit_EncodingAndDecoding(t *testing.T) { addr, err := address.ParseAddr("EQDtFpEwcFAEcRe5mLVh2N6C0x-_hJEM7W61_JLnSF74p4q2") require.NoError(t, err) diff --git a/pkg/ccip/bindings/onramp/onramp.go b/pkg/ccip/bindings/onramp/onramp.go index 095138204..e150efe10 100644 --- a/pkg/ccip/bindings/onramp/onramp.go +++ b/pkg/ccip/bindings/onramp/onramp.go @@ -33,6 +33,8 @@ const ( TopicDestChainSelectorAdded = 0xD3D104FF // CRC32("DestChainSelectorAdded") TopicDestChainConfigUpdated = 0x3AA25CF1 // CRC32("DestChainConfigUpdated") TopicConfigSet = 0x1E32222C // CRC32("ConfigSet") + // TopicDynamicConfigSet is an alias used by some tooling for the ConfigSet event. + TopicDynamicConfigSet = TopicConfigSet ) // Registry method names @@ -63,6 +65,9 @@ type ConfigSet struct { DynamicConfig DynamicConfig `tlb:"."` } +// DynamicConfigSet is an alias for ConfigSet kept for naming consistency with logs tooling. +type DynamicConfigSet = ConfigSet + // GenericExtraArgsV2 represents generic extra arguments for transactions. type GenericExtraArgsV2 struct { _ tlb.Magic `tlb:"#181dcf10" json:"-"` //nolint:revive // Ignore opcode tag // hex encoded bytes4(keccak256("CCIP EVMExtraArgsV2")), can be verified with hexutil.MustDecode("0x181dcf10") diff --git a/pkg/ccip/bindings/onramp/onramp_test.go b/pkg/ccip/bindings/onramp/onramp_test.go index 1f14d6955..086cab229 100644 --- a/pkg/ccip/bindings/onramp/onramp_test.go +++ b/pkg/ccip/bindings/onramp/onramp_test.go @@ -43,6 +43,11 @@ func TestTopicCRC32Values(t *testing.T) { topic: "ConfigSet", expected: TopicConfigSet, }, + { + name: "TopicDynamicConfigSetAlias", + topic: "ConfigSet", + expected: TopicDynamicConfigSet, + }, } for _, tt := range tests { diff --git a/pkg/ccip/bindings/receiveexecutor/receiveexecutor.go b/pkg/ccip/bindings/receiveexecutor/receiveexecutor.go new file mode 100644 index 000000000..1c21585ab --- /dev/null +++ b/pkg/ccip/bindings/receiveexecutor/receiveexecutor.go @@ -0,0 +1,59 @@ +package receiveexecutor + +import ( + "math/big" + + "github.com/xssnick/tonutils-go/address" + "github.com/xssnick/tonutils-go/tlb" + + "github.com/smartcontractkit/chainlink-ton/pkg/ccip/bindings/ocr" + "github.com/smartcontractkit/chainlink-ton/pkg/ton/tvm" +) + +// InitExecute represents the ReceiveExecutor_InitExecute message. +type InitExecute struct { + _ tlb.Magic `tlb:"#64cd2fd2" json:"-"` //nolint:revive // Ignore opcode tag + GasOverride *tlb.Coins `tlb:"maybe ."` + Root *address.Address `tlb:"addr"` + SequenceNumber uint64 `tlb:"## 64"` + SourceChainSelector uint64 `tlb:"## 64"` + MessageID *big.Int `tlb:"## 256"` +} + +// Confirm represents the ReceiveExecutor_Confirm message. +type Confirm struct { + _ tlb.Magic `tlb:"#00e5dd97" json:"-"` //nolint:revive // Ignore opcode tag + Receiver *address.Address `tlb:"addr"` +} + +// Bounced represents the ReceiveExecutor_Bounced message. +type Bounced struct { + _ tlb.Magic `tlb:"#05dee1bb" json:"-"` //nolint:revive // Ignore opcode tag + Receiver *address.Address `tlb:"addr"` +} + +// MessageState is the ReceiveExecutor message state machine state. +type MessageState uint8 + +const ( + MessageStateUntouched MessageState = iota + MessageStateExecute + MessageStateExecuteFailed + MessageStateSuccess +) + +// Storage represents ReceiveExecutor storage state. +type Storage struct { + Owner *address.Address `tlb:"addr"` + Message ocr.Any2TVMRampMessage `tlb:"^"` + Root *address.Address `tlb:"addr"` + ExecID *big.Int `tlb:"## 192"` + State MessageState `tlb:"## 8"` + LastExecutionTimestamp uint64 `tlb:"## 64"` +} + +var TLBs = tvm.MustNewTLBMap([]any{ + InitExecute{}, + Bounced{}, + Confirm{}, +}).MustWithStorageType(Storage{}) diff --git a/pkg/ccip/bindings/receiver/receiver.go b/pkg/ccip/bindings/receiver/receiver.go index 4df97b391..1e9e771dd 100644 --- a/pkg/ccip/bindings/receiver/receiver.go +++ b/pkg/ccip/bindings/receiver/receiver.go @@ -2,17 +2,11 @@ package receiver import ( "github.com/xssnick/tonutils-go/address" + "github.com/xssnick/tonutils-go/tlb" "github.com/smartcontractkit/chainlink-ton/pkg/ccip/bindings/offramp" "github.com/smartcontractkit/chainlink-ton/pkg/ccip/bindings/ownable2step" -) - -type Behavior uint8 - -const ( - Accept Behavior = iota - RejectAll - ConsumeAllGas + "github.com/smartcontractkit/chainlink-ton/pkg/ton/tvm" ) // Storage represents the storage structure for the CCIP receiver contract. @@ -23,6 +17,29 @@ type Storage struct { Behavior Behavior `tlb:"## 8"` } +type Behavior uint8 + +const ( + BehaviorAccept Behavior = iota + BehaviorRejectAll + BehaviorConsumeAllGas +) + +type UpdateBehavior struct { + _ tlb.Magic `tlb:"#cf87a147" json:"-"` //nolint:revive // (opcode) should stay uninitialized + Behavior Behavior `tlb:"## 8"` +} + +type UpdateAuthorizedCaller struct { + _ tlb.Magic `tlb:"#9f5e489f" json:"-"` //nolint:revive // (opcode) should stay uninitialized + AuthorizedCaller *address.Address `tlb:"addr"` +} + +var TLBs = tvm.MustNewTLBMap([]any{ + UpdateBehavior{}, + UpdateAuthorizedCaller{}, +}).MustWithStorageType(Storage{}) + // CCIPMessageReceivedEventTopic is the event topic for Receiver_CCIPMessageReceived event // crc32('Receiver_CCIPMessageReceived') = 0xc5a40ab3 const CCIPMessageReceivedEventTopic = 0xc5a40ab3 diff --git a/pkg/ccip/bindings/receiver/receiver_test.go b/pkg/ccip/bindings/receiver/receiver_test.go new file mode 100644 index 000000000..4f1d06d08 --- /dev/null +++ b/pkg/ccip/bindings/receiver/receiver_test.go @@ -0,0 +1,13 @@ +package receiver + +import ( + "hash/crc32" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCCIPMessageReceivedEventTopicCRC32(t *testing.T) { + computed := crc32.ChecksumIEEE([]byte("Receiver_CCIPMessageReceived")) + require.Equal(t, uint32(CCIPMessageReceivedEventTopic), computed) +} diff --git a/pkg/ccip/bindings/router/reader.go b/pkg/ccip/bindings/router/reader.go index ee61f363a..1d0744ab1 100644 --- a/pkg/ccip/bindings/router/reader.go +++ b/pkg/ccip/bindings/router/reader.go @@ -59,3 +59,15 @@ var GetVerifyNotCursed = tvm.Getter[*big.Int, bool]{ return notCursed.Cmp(big.NewInt(0)) != 0, nil }), } + +// GetCursedSubjects gets all cursed subjects. +var GetCursedSubjects = tvm.Getter[struct{}, []*big.Int]{ + Name: "cursedSubjects", + Decoder: tvm.NewResultDecoder(func(r *ton.ExecutionResult) ([]*big.Int, error) { + subjects, err := parser.ParseLispTuple[*big.Int](r.AsTuple()) + if err != nil { + return nil, err + } + return subjects, nil + }), +} diff --git a/pkg/ton/codec/debug/decoders/ccip/ccipsendexecutor/ccipsendexecutor.go b/pkg/ton/codec/debug/decoders/ccip/ccipsendexecutor/ccipsendexecutor.go index 1c17908b4..a7b55b64b 100644 --- a/pkg/ton/codec/debug/decoders/ccip/ccipsendexecutor/ccipsendexecutor.go +++ b/pkg/ton/codec/debug/decoders/ccip/ccipsendexecutor/ccipsendexecutor.go @@ -27,7 +27,7 @@ func (d *decoder) ContractType() string { } func (d *decoder) EventInfo(dstAddr *address.Address, msg *cell.Cell) (lib.MessageInfo, error) { - return nil, codec.ErrUnknownMessage + return lib.NewMessageInfoFromCell(d.ContractType(), msg, TLBs, d.tlbsCtx) } func (d *decoder) ExternalMessageInfo(msg *cell.Cell) (lib.MessageInfo, error) { diff --git a/pkg/ton/codec/debug/decoders/ccip/feequoter/feequoter.go b/pkg/ton/codec/debug/decoders/ccip/feequoter/feequoter.go index 454b861f5..2e2f5ea3e 100644 --- a/pkg/ton/codec/debug/decoders/ccip/feequoter/feequoter.go +++ b/pkg/ton/codec/debug/decoders/ccip/feequoter/feequoter.go @@ -27,7 +27,7 @@ func (d *decoder) ContractType() string { } func (d *decoder) EventInfo(dstAddr *address.Address, msg *cell.Cell) (lib.MessageInfo, error) { - return nil, codec.ErrUnknownMessage + return lib.NewMessageInfoFromCell(d.ContractType(), msg, TLBs, d.tlbsCtx) } func (d *decoder) ExternalMessageInfo(msg *cell.Cell) (lib.MessageInfo, error) { diff --git a/pkg/ton/codec/debug/decoders/ccip/merkler_root/decoder.go b/pkg/ton/codec/debug/decoders/ccip/merkler_root/decoder.go new file mode 100644 index 000000000..ed041732e --- /dev/null +++ b/pkg/ton/codec/debug/decoders/ccip/merkler_root/decoder.go @@ -0,0 +1,47 @@ +package merkle_root + +import ( + "github.com/xssnick/tonutils-go/address" + "github.com/xssnick/tonutils-go/tvm/cell" + + "github.com/smartcontractkit/chainlink-ton/pkg/bindings" + "github.com/smartcontractkit/chainlink-ton/pkg/ccip/bindings/merkleroot" + "github.com/smartcontractkit/chainlink-ton/pkg/ton/codec" + "github.com/smartcontractkit/chainlink-ton/pkg/ton/codec/debug/lib" + "github.com/smartcontractkit/chainlink-ton/pkg/ton/tvm" +) + +var TLBs = merkleroot.TLBs + +type decoder struct { + tlbsCtx tvm.TLBMap +} + +func NewDecoder(tlbsCtx tvm.TLBMap) lib.ContractDecoder { + return &decoder{tlbsCtx} +} + +func (d *decoder) ContractType() string { + return bindings.TypeMerkleRoot +} + +func (d *decoder) EventInfo(dstAddr *address.Address, msg *cell.Cell) (lib.MessageInfo, error) { + return nil, codec.ErrUnknownMessage +} + +func (d *decoder) ExternalMessageInfo(msg *cell.Cell) (lib.MessageInfo, error) { + return nil, codec.ErrUnknownMessage +} + +func (d *decoder) InternalMessageInfo(msg *cell.Cell) (lib.MessageInfo, error) { + return lib.NewMessageInfoFromCell(d.ContractType(), msg, TLBs, d.tlbsCtx) +} + +func (d *decoder) ExitCodeInfo(exitCode tvm.ExitCode) (string, error) { + ec, err := merkleroot.ExitCodeCodec.NewFrom(exitCode) + if err != nil { + return "", codec.ErrUnknownMessage + } + + return ec.String(), nil +} diff --git a/pkg/ton/codec/debug/decoders/ccip/offramp/decoder.go b/pkg/ton/codec/debug/decoders/ccip/offramp/decoder.go index ea4429e99..f44589a6a 100644 --- a/pkg/ton/codec/debug/decoders/ccip/offramp/decoder.go +++ b/pkg/ton/codec/debug/decoders/ccip/offramp/decoder.go @@ -2,12 +2,14 @@ package offramp import ( "github.com/xssnick/tonutils-go/address" + "github.com/xssnick/tonutils-go/tlb" "github.com/xssnick/tonutils-go/tvm/cell" "github.com/smartcontractkit/chainlink-ton/pkg/bindings" "github.com/smartcontractkit/chainlink-ton/pkg/ccip/bindings/offramp" "github.com/smartcontractkit/chainlink-ton/pkg/ton/codec" "github.com/smartcontractkit/chainlink-ton/pkg/ton/codec/debug/lib" + "github.com/smartcontractkit/chainlink-ton/pkg/ton/event" "github.com/smartcontractkit/chainlink-ton/pkg/ton/tvm" ) @@ -26,7 +28,64 @@ func (d *decoder) ContractType() string { } func (d *decoder) EventInfo(dstAddr *address.Address, msg *cell.Cell) (lib.MessageInfo, error) { - return nil, codec.ErrUnknownMessage + bucket := event.NewExtOutLogBucket(dstAddr) + topic, err := bucket.DecodeEventTopic() + if err != nil { + return nil, codec.ErrUnknownMessage + } + + switch topic { + case offramp.TopicExecutionStateChanged: + var eventData offramp.ExecutionStateChanged + if err := tlb.LoadFromCell(&eventData, msg.BeginParse()); err != nil { + return nil, err + } + return lib.NewMessageInfo("ExecutionStateChanged", eventData) + case offramp.TopicCommitReportAccepted: + var eventData offramp.CommitReportAccepted + if err := tlb.LoadFromCell(&eventData, msg.BeginParse()); err != nil { + return nil, err + } + return lib.NewMessageInfo("CommitReportAccepted", eventData) + case offramp.TopicSourceChainSelectorAdded: + var eventData offramp.SourceChainSelectorAdded + if err := tlb.LoadFromCell(&eventData, msg.BeginParse()); err != nil { + return nil, err + } + return lib.NewMessageInfo("SourceChainSelectorAdded", eventData) + case offramp.TopicSourceChainConfigUpdated: + var eventData offramp.SourceChainConfigUpdated + if err := tlb.LoadFromCell(&eventData, msg.BeginParse()); err != nil { + return nil, err + } + return lib.NewMessageInfo("SourceChainConfigUpdated", eventData) + case offramp.TopicDynamicConfigSet: + var eventData offramp.DynamicConfigSet + if err := tlb.LoadFromCell(&eventData, msg.BeginParse()); err != nil { + return nil, err + } + return lib.NewMessageInfo("DynamicConfigSet", eventData) + case offramp.TopicReceiveExecutorInitExecuteBounced: + var eventData offramp.ReceiveExecutorInitExecuteBounced + if err := tlb.LoadFromCell(&eventData, msg.BeginParse()); err != nil { + return nil, err + } + return lib.NewMessageInfo("ReceiveExecutorInitExecuteBounced", eventData) + case offramp.TopicDeployableInitializeBounced: + var eventData offramp.DeployableInitializeBounced + if err := tlb.LoadFromCell(&eventData, msg.BeginParse()); err != nil { + return nil, err + } + return lib.NewMessageInfo("DeployableInitializeBounced", eventData) + case offramp.TopicRouteMessageBounced: + var eventData offramp.RouteMessageBounced + if err := tlb.LoadFromCell(&eventData, msg.BeginParse()); err != nil { + return nil, err + } + return lib.NewMessageInfo("RouteMessageBounced", eventData) + default: + return nil, codec.ErrUnknownMessage + } } func (d *decoder) ExternalMessageInfo(msg *cell.Cell) (lib.MessageInfo, error) { diff --git a/pkg/ton/codec/debug/decoders/ccip/onramp/onramp.go b/pkg/ton/codec/debug/decoders/ccip/onramp/onramp.go index 6ba256ff8..03c44298c 100644 --- a/pkg/ton/codec/debug/decoders/ccip/onramp/onramp.go +++ b/pkg/ton/codec/debug/decoders/ccip/onramp/onramp.go @@ -35,16 +35,38 @@ func (d *decoder) EventInfo(dstAddr *address.Address, msg *cell.Cell) (lib.Messa if err != nil { return nil, codec.ErrUnknownMessage } - if topic == onramp.TopicCCIPMessageSent { + switch topic { + case onramp.TopicCCIPMessageSent: var ccipMessageSent onramp.CCIPMessageSent err := tlb.LoadFromCell(&ccipMessageSent, msg.BeginParse()) if err != nil { return nil, err } return lib.NewMessageInfo("CCIPMessageSent", ccipMessageSent) + case onramp.TopicDestChainSelectorAdded: + var destChainSelectorAdded onramp.DestChainSelectorAdded + err := tlb.LoadFromCell(&destChainSelectorAdded, msg.BeginParse()) + if err != nil { + return nil, err + } + return lib.NewMessageInfo("DestChainSelectorAdded", destChainSelectorAdded) + case onramp.TopicDestChainConfigUpdated: + var destChainConfigUpdated onramp.DestChainConfigUpdated + err := tlb.LoadFromCell(&destChainConfigUpdated, msg.BeginParse()) + if err != nil { + return nil, err + } + return lib.NewMessageInfo("DestChainConfigUpdated", destChainConfigUpdated) + case onramp.TopicConfigSet: + var configSet onramp.ConfigSet + err := tlb.LoadFromCell(&configSet, msg.BeginParse()) + if err != nil { + return nil, err + } + return lib.NewMessageInfo("ConfigSet", configSet) + default: + return nil, codec.ErrUnknownMessage } - - return nil, codec.ErrUnknownMessage } func (d *decoder) ExternalMessageInfo(msg *cell.Cell) (lib.MessageInfo, error) { diff --git a/pkg/ton/codec/debug/decoders/ccip/receiveexecutor/receiveexecutor.go b/pkg/ton/codec/debug/decoders/ccip/receiveexecutor/receiveexecutor.go new file mode 100644 index 000000000..a2e79bd7a --- /dev/null +++ b/pkg/ton/codec/debug/decoders/ccip/receiveexecutor/receiveexecutor.go @@ -0,0 +1,42 @@ +package receiveexecutor + +import ( + "github.com/xssnick/tonutils-go/address" + "github.com/xssnick/tonutils-go/tvm/cell" + + "github.com/smartcontractkit/chainlink-ton/pkg/bindings" + receiveexecutorbindings "github.com/smartcontractkit/chainlink-ton/pkg/ccip/bindings/receiveexecutor" + "github.com/smartcontractkit/chainlink-ton/pkg/ton/codec" + "github.com/smartcontractkit/chainlink-ton/pkg/ton/codec/debug/lib" + "github.com/smartcontractkit/chainlink-ton/pkg/ton/tvm" +) + +var TLBs = receiveexecutorbindings.TLBs + +type decoder struct { + tlbsCtx tvm.TLBMap +} + +func NewDecoder(tlbsCtx tvm.TLBMap) lib.ContractDecoder { + return &decoder{tlbsCtx} +} + +func (d *decoder) ContractType() string { + return bindings.TypeReceiveExecutor +} + +func (d *decoder) EventInfo(dstAddr *address.Address, msg *cell.Cell) (lib.MessageInfo, error) { + return lib.NewMessageInfoFromCell(d.ContractType(), msg, TLBs, d.tlbsCtx) +} + +func (d *decoder) ExternalMessageInfo(msg *cell.Cell) (lib.MessageInfo, error) { + return nil, codec.ErrUnknownMessage +} + +func (d *decoder) InternalMessageInfo(msg *cell.Cell) (lib.MessageInfo, error) { + return lib.NewMessageInfoFromCell(d.ContractType(), msg, TLBs, d.tlbsCtx) +} + +func (d *decoder) ExitCodeInfo(exitCode tvm.ExitCode) (string, error) { + return "", codec.ErrUnknownMessage +} diff --git a/pkg/ton/codec/debug/decoders/ccip/receiver/decoder.go b/pkg/ton/codec/debug/decoders/ccip/receiver/decoder.go new file mode 100644 index 000000000..bb7c78f36 --- /dev/null +++ b/pkg/ton/codec/debug/decoders/ccip/receiver/decoder.go @@ -0,0 +1,49 @@ +package receiver + +import ( + "github.com/xssnick/tonutils-go/address" + "github.com/xssnick/tonutils-go/tvm/cell" + + "github.com/smartcontractkit/chainlink-ton/pkg/bindings" + "github.com/smartcontractkit/chainlink-ton/pkg/ccip/bindings/receiver" + "github.com/smartcontractkit/chainlink-ton/pkg/ton/codec" + "github.com/smartcontractkit/chainlink-ton/pkg/ton/codec/debug/lib" + "github.com/smartcontractkit/chainlink-ton/pkg/ton/tvm" +) + +var TLBs = receiver.TLBs + +type decoder struct { + tlbsCtx tvm.TLBMap +} + +func NewDecoder(tlbsCtx tvm.TLBMap) lib.ContractDecoder { + return &decoder{tlbsCtx} +} + +func (d *decoder) ContractType() string { + return bindings.TypeReceiver +} + +func (d *decoder) EventInfo(dstAddr *address.Address, msg *cell.Cell) (lib.MessageInfo, error) { + return nil, codec.ErrUnknownMessage +} + +func (d *decoder) ExternalMessageInfo(msg *cell.Cell) (lib.MessageInfo, error) { + return lib.NewMessageInfoFromCell(d.ContractType(), msg, TLBs, d.tlbsCtx) +} + +func (d *decoder) InternalMessageInfo(msg *cell.Cell) (lib.MessageInfo, error) { + return lib.NewMessageInfoFromCell(d.ContractType(), msg, TLBs, d.tlbsCtx) +} + +func (d *decoder) ExitCodeInfo(exitCode tvm.ExitCode) (string, error) { + return "", codec.ErrUnknownMessage + // TODO + // ec, err := receiver.ExitCodeCodec.NewFrom(exitCode) + // if err != nil { + // return "", codec.ErrUnknownMessage + // } + + // return ec.String(), nil +} diff --git a/pkg/ton/codec/debug/decoders/ccip/router/router.go b/pkg/ton/codec/debug/decoders/ccip/router/router.go index c12b19819..eea475011 100644 --- a/pkg/ton/codec/debug/decoders/ccip/router/router.go +++ b/pkg/ton/codec/debug/decoders/ccip/router/router.go @@ -27,7 +27,7 @@ func (d *decoder) ContractType() string { } func (d *decoder) EventInfo(dstAddr *address.Address, msg *cell.Cell) (lib.MessageInfo, error) { - return nil, codec.ErrUnknownMessage + return lib.NewMessageInfoFromCell(d.ContractType(), msg, TLBs, d.tlbsCtx) } func (d *decoder) ExternalMessageInfo(msg *cell.Cell) (lib.MessageInfo, error) { diff --git a/pkg/ton/codec/debug/decoders/jetton/common.go b/pkg/ton/codec/debug/decoders/jetton/common.go index b4a98222f..ba6a77e23 100644 --- a/pkg/ton/codec/debug/decoders/jetton/common.go +++ b/pkg/ton/codec/debug/decoders/jetton/common.go @@ -29,7 +29,7 @@ func (d *decoder) ContractType() string { // EventInfo implements lib.ContractDecoder. func (d *decoder) EventInfo(dstAddr *address.Address, msg *cell.Cell) (lib.MessageInfo, error) { - return nil, codec.ErrUnknownMessage + return lib.NewMessageInfoFromCell(d.ContractType(), msg, TLBs, d.tlbsCtx) } // ExternalMessageInfo implements lib.ContractDecoder. diff --git a/pkg/ton/codec/debug/decoders/jetton/minter/jetton_minter.go b/pkg/ton/codec/debug/decoders/jetton/minter/jetton_minter.go index 92eed930d..3d08c3eae 100644 --- a/pkg/ton/codec/debug/decoders/jetton/minter/jetton_minter.go +++ b/pkg/ton/codec/debug/decoders/jetton/minter/jetton_minter.go @@ -29,7 +29,11 @@ func (d *decoder) ContractType() string { // EventInfo implements lib.ContractDecoder. func (d *decoder) EventInfo(dstAddr *address.Address, msg *cell.Cell) (lib.MessageInfo, error) { - return nil, codec.ErrUnknownMessage + info, err := lib.NewMessageInfoFromCell(d.ContractType(), msg, TLBs, d.tlbsCtx) + if err != nil { + return jetton.NewDecoder(d.tlbsCtx, d.ContractType()).InternalMessageInfo(msg) + } + return info, nil } // ExternalMessageInfo implements lib.ContractDecoder. diff --git a/pkg/ton/codec/debug/decoders/jetton/wallet/jetton_wallet.go b/pkg/ton/codec/debug/decoders/jetton/wallet/jetton_wallet.go index 8a8ced5dd..dd4fb0c55 100644 --- a/pkg/ton/codec/debug/decoders/jetton/wallet/jetton_wallet.go +++ b/pkg/ton/codec/debug/decoders/jetton/wallet/jetton_wallet.go @@ -29,7 +29,11 @@ func (d *decoder) ContractType() string { // EventInfo implements lib.ContractDecoder. func (d *decoder) EventInfo(dstAddr *address.Address, msg *cell.Cell) (lib.MessageInfo, error) { - return nil, codec.ErrUnknownMessage + info, err := lib.NewMessageInfoFromCell(d.ContractType(), msg, TLBs, d.tlbsCtx) + if err != nil { + return jetton_common.NewDecoder(d.tlbsCtx, d.ContractType()).InternalMessageInfo(msg) + } + return info, nil } // ExternalMessageInfo implements lib.ContractDecoder. diff --git a/pkg/ton/codec/debug/decoders/lib/access/rbac/decoder.go b/pkg/ton/codec/debug/decoders/lib/access/rbac/decoder.go index 45f995b61..2711260f6 100644 --- a/pkg/ton/codec/debug/decoders/lib/access/rbac/decoder.go +++ b/pkg/ton/codec/debug/decoders/lib/access/rbac/decoder.go @@ -27,7 +27,7 @@ func (d *decoder) ContractType() string { } func (d *decoder) EventInfo(dstAddr *address.Address, msg *cell.Cell) (lib.MessageInfo, error) { - return nil, codec.ErrUnknownMessage + return lib.NewMessageInfoFromCell(d.ContractType(), msg, TLBs, d.tlbsCtx) } func (d *decoder) ExternalMessageInfo(msg *cell.Cell) (lib.MessageInfo, error) { diff --git a/pkg/ton/codec/debug/decoders/mcms/mcms/decoder.go b/pkg/ton/codec/debug/decoders/mcms/mcms/decoder.go index 777e1d6b9..575d63b50 100644 --- a/pkg/ton/codec/debug/decoders/mcms/mcms/decoder.go +++ b/pkg/ton/codec/debug/decoders/mcms/mcms/decoder.go @@ -27,7 +27,7 @@ func (d *decoder) ContractType() string { } func (d *decoder) EventInfo(dstAddr *address.Address, msg *cell.Cell) (lib.MessageInfo, error) { - return nil, codec.ErrUnknownMessage + return lib.NewMessageInfoFromCell(d.ContractType(), msg, TLBs, d.tlbsCtx) } func (d *decoder) ExternalMessageInfo(msg *cell.Cell) (lib.MessageInfo, error) { diff --git a/pkg/ton/codec/debug/decoders/mcms/timelock/decoder.go b/pkg/ton/codec/debug/decoders/mcms/timelock/decoder.go index 702481366..44d319f7d 100644 --- a/pkg/ton/codec/debug/decoders/mcms/timelock/decoder.go +++ b/pkg/ton/codec/debug/decoders/mcms/timelock/decoder.go @@ -27,7 +27,7 @@ func (d *decoder) ContractType() string { } func (d *decoder) EventInfo(dstAddr *address.Address, msg *cell.Cell) (lib.MessageInfo, error) { - return nil, codec.ErrUnknownMessage + return lib.NewMessageInfoFromCell(d.ContractType(), msg, TLBs, d.tlbsCtx) } func (d *decoder) ExternalMessageInfo(msg *cell.Cell) (lib.MessageInfo, error) { 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 23267a1d6..be0941c0c 100644 --- a/pkg/ton/codec/debug/explorer/explorer.go +++ b/pkg/ton/codec/debug/explorer/explorer.go @@ -2,22 +2,12 @@ package explorer import ( "context" - "encoding/hex" - "encoding/json" "errors" "fmt" - "net/http" - "net/url" - "os/exec" - "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" @@ -31,30 +21,36 @@ 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{ - Use: "explorer
| ", + Use: "explorer", Short: "TON blockchain explorer and trace analyzer", Long: `A command-line tool for exploring TON blockchain transactions and analyzing traces. This tool helps debug and understand transaction flows on the TON network. Usage: - explorer
- Analyze transaction with address and hash - explorer - Analyze transaction from URL + explorer trace [address] - Analyze transaction with address and hash + explorer trace [address] - Analyze transaction from URL + explorer get
[getter_name] [args...] - Execute a getter on a contract Arguments: address Destination address in base64 tx-hash Transaction hash in hex - url tonscan TX URL`, + url tonscan TX URL + getter_name Getter name registered in bindings.GetterMap + args Optional getter arguments`, + } + + traceCmd := &cobra.Command{ + Use: "trace [address] | [address]", + Short: "Analyze a TON transaction trace", Args: func(cmd *cobra.Command, args []string) error { if len(args) != 1 && len(args) != 2 { return errors.New("requires 1 argument (URL) or 2 arguments (
)") @@ -79,32 +75,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) @@ -115,7 +91,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) } @@ -123,139 +99,23 @@ 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)") + traceCmd.Flags().StringVarP(&visualization, "visualization", "V", "sequence", "Visualization format (sequence or tree)") + traceCmd.Flags().StringVarP(&format, "format", "f", "", "Sequence visualization format (url or raw) (only for sequence visualization)") + traceCmd.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") - } - cmd.Flags().Uint32VarP(&pageSize, "page-size", "s", 10, "Number of blocks to fetch per page") - cmd.Flags().Uint32VarP(&maxPages, "max-pages", "p", 10, "Maximum number of pages to fetch") - - 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) + traceCmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "Shows full body of unmatched messages") } + traceCmd.Flags().Uint32VarP(&pageSize, "page-size", "s", 10, "Number of blocks to fetch per page") + traceCmd.Flags().Uint32VarP(&maxPages, "max-pages", "p", 10, "Maximum number of pages to fetch") - inspect := &inspects[0] + cmd.AddCommand(traceCmd) + cmd.AddCommand(newGetCmd(lggr, contracts, client)) - 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 + return cmd } // 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 @@ -291,45 +151,33 @@ type client struct { maxPages uint32 } -type Format int - -const ( - FormatTree Format = iota - FormatSequenceURL - FormatSequenceRaw -) +func (c *client) resilientAPI() ton.APIClientWrapped { return c.connection.WithRetry(5) } -// 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 { - 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) - 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 { - senderAddr, err = address.ParseAddr(srcAddrStr) - if err != nil { - return fmt.Errorf("failed to parse transaction address: %w", err) + api := c.resilientAPI() + effectiveTxHash := txHashStr + 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) } } - txHash, err := hex.DecodeString(txHashStr) + + 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 { 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, effectiveTxHash, decodedTxHash) if err != nil { return err } @@ -342,22 +190,17 @@ func (c *client) PrintTrace(ctx context.Context, txHashStr string, srcAddrStr st } c.lggr.Info("waiting for full trace...") - - err = recvMsg.WaitForTrace(ctx, c.connection) - 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, &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: @@ -369,87 +212,116 @@ 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 (c *client) queryActors(ctx context.Context, message *tracetracking.ReceivedMessage, knownActors map[string]debug.TypeAndVersion) error { +func resolveSenderAddress(ctx context.Context, c *client, srcAddrStr string, effectiveTxHash string, txHashStr string) (*address.Address, 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 := 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) - return err - } else if message.ExternalMsg != nil { - err := c.queryActorIfNotVisited(ctx, block, message.ExternalMsg.DstAddr, knownActors, visited) + 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, 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) } -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) - if err != nil { + if err := c.queryActorsReceivedRec(ctx, api, block, outMsg, knownActors, visited); err != nil { return err } } 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) 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 := 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? + 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) } @@ -461,169 +333,3 @@ func (c *client) queryActorIfNotVisited(ctx context.Context, block *ton.BlockIDE } return nil } - -func (c *client) GetSenderAddressFromTxHash(ctx context.Context, txHashStr string) (*address.Address, 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) - } - 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 { - 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 apiResponse - 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") - } - addr, err := address.ParseRawAddr(respData.Transactions[0].Account) - if err != nil { - return nil, fmt.Errorf("failed to parse source address from toncenter response: %w", err) - } - return addr, nil -} - -func (c *client) findTx(ctx context.Context, api *ton.APIClient, srcAddr *address.Address, 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) - } - 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 - } - return nil, errors.New("transaction not found in searched range. Try increasing --page-size and --max-pages") -} - -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/get.go b/pkg/ton/codec/debug/explorer/get.go new file mode 100644 index 000000000..2b52ddbd8 --- /dev/null +++ b/pkg/ton/codec/debug/explorer/get.go @@ -0,0 +1,306 @@ +package explorer + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "reflect" + "strconv" + "strings" + + "github.com/spf13/cobra" + "github.com/xssnick/tonutils-go/address" + "github.com/xssnick/tonutils-go/ton" + "go.uber.org/zap/zapcore" + + "github.com/smartcontractkit/chainlink-common/pkg/logger" + + "golang.org/x/term" + + "github.com/smartcontractkit/chainlink-ton/pkg/bindings" + "github.com/smartcontractkit/chainlink-ton/pkg/ccip/bindings/common" + "github.com/smartcontractkit/chainlink-ton/pkg/ton/codec/debug" + "github.com/smartcontractkit/chainlink-ton/pkg/ton/tvm" +) + +func newGetCmd(lggr *logger.Logger, contracts map[string]debug.TypeAndVersion, apiClient *ton.APIClient) *cobra.Command { + var ( + net string + verbose bool + contractType string + namedArgs []string + ) + + cmd := &cobra.Command{ + Use: "get
[getter_name] [args...]", + Short: "Execute a getter", + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + if len(args) > 1 && args[1] != "" { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + targetAddr, err := address.ParseAddr(args[0]) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + log, err := buildCmdLogger(lggr, false) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + if apiClient != nil && cmd.Flags().Changed("net") { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + ctx := context.Background() + explorerClient, err := Connect(log, apiClient, net, false, 1, 1) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + effectiveContractType, err := resolveContractType(ctx, explorerClient, targetAddr, contractType, contracts) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + candidates := listGetterNames(effectiveContractType) + if toComplete == "" { + return candidates, cobra.ShellCompDirectiveNoFileComp + } + + filtered := make([]string, 0, len(candidates)) + for _, candidate := range candidates { + if strings.HasPrefix(candidate, toComplete) { + filtered = append(filtered, candidate) + } + } + + return filtered, cobra.ShellCompDirectiveNoFileComp + }, + Args: func(cmd *cobra.Command, args []string) error { + if len(args) < 1 { + return errors.New("requires
") + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) < 1 { + return errors.New("requires
") + } + + log, err := buildCmdLogger(lggr, verbose) + if err != nil { + return err + } + + if apiClient != nil && cmd.Flags().Changed("net") { + return errors.New("cannot specify network flag when using existing client") + } + + targetAddr, err := address.ParseAddr(args[0]) + if err != nil { + return fmt.Errorf("failed to parse contract address: %w", err) + } + + ctx := context.Background() + explorerClient, err := Connect(log, apiClient, net, verbose, 1, 1) + if err != nil { + return fmt.Errorf("failed to initialize explorer: %w", err) + } + + effectiveContractType, err := resolveContractType(ctx, explorerClient, targetAddr, contractType, contracts) + if err != nil { + return err + } + + availableGetters := listGetterNames(effectiveContractType) + if len(availableGetters) == 0 { + return fmt.Errorf("no getters registered for %q", effectiveContractType) + } + + getterName := "" + if len(args) >= 2 { + getterName = args[1] + } else { + selected, selectErr := selectGetterInteractive(cmd, targetAddr.String(), effectiveContractType, availableGetters) + if selectErr != nil { + return selectErr + } + getterName = selected + } + + desc, err := resolveGetterDescriptor(effectiveContractType, getterName) + if err != nil { + return err + } + + named, err := parseNamedArgs(namedArgs) + if err != nil { + return err + } + + positional := []string{} + if len(args) > 2 { + positional = args[2:] + } + + interactive := term.IsTerminal(int(os.Stdin.Fd())) + inputValue, err := buildGetterInput(desc, positional, named, interactive, promptForArgValue) + if err != nil { + return err + } + + params, err := desc.Encode(inputValue) + if err != nil { + return fmt.Errorf("failed to encode getter %q args: %w", getterName, err) + } + + api := explorerClient.resilientAPI() + block, err := api.CurrentMasterchainInfo(ctx) + if err != nil { + return fmt.Errorf("failed to get current block: %w", err) + } + + result, err := api.RunGetMethod(ctx, block, targetAddr, desc.MethodName, params...) + if err != nil { + return fmt.Errorf("failed to run getter %q: %w", desc.MethodName, err) + } + decoded, err := desc.Decode(result) + if err != nil { + return fmt.Errorf("failed to decode getter %q result: %w", desc.MethodName, err) + } + + fmt.Fprintln(cmd.OutOrStdout(), formatGetterResult(decoded)) + return nil + }, + } + + cmd.Flags().StringVarP(&net, "net", "n", "testnet", "TON network (mainnet, testnet, mylocalton, or http://domain/x.global.config.json)") + cmd.Flags().StringVarP(&contractType, "contract-type", "t", "", "Contract type (e.g. link.chain.ton.ccip.Router)") + cmd.Flags().StringArrayVar(&namedArgs, "arg", nil, "Getter argument in name=value form (repeatable)") + if lggr == nil { + cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "Enable debug logs") + } + + return cmd +} + +func buildCmdLogger(lggr *logger.Logger, verbose bool) (logger.Logger, error) { + if lggr != nil { + return *lggr, nil + } + + config := logger.Config{} + if verbose { + config.Level = zapcore.DebugLevel + } + log, err := config.New() + if err != nil { + return nil, fmt.Errorf("failed to create logger: %w", err) + } + return log, nil +} + +func resolveContractType(ctx context.Context, c *client, targetAddr *address.Address, contractTypeFlag string, contracts map[string]debug.TypeAndVersion) (string, error) { + if contractTypeFlag != "" { + return contractTypeFlag, nil + } + + if tv, ok := contracts[targetAddr.String()]; ok && tv.Type != "" { + return tv.Type, nil + } + + api := c.resilientAPI() + block, err := api.CurrentMasterchainInfo(ctx) + if err != nil { + return "", fmt.Errorf("failed to get current block for contract type resolution: %w", err) + } + + result, err := api.RunGetMethod(ctx, block, targetAddr, common.GetTypeAndVersion.Name) + if err != nil { + return "", errors.New("contract type is required; pass --contract-type (automatic resolution via typeAndVersion failed)") + } + decoded, err := common.GetTypeAndVersion.Decoder.Decode(result) + if err != nil { + return "", errors.New("contract type is required; pass --contract-type (failed to decode typeAndVersion)") + } + if decoded.Type == "" { + return "", errors.New("contract type is required; pass --contract-type") + } + + return decoded.Type, nil +} + +func resolveRegisteredGetter(contractType string, getterName string) (any, bool) { + mapsToSearch := []bindings.GetterMap{ + bindings.TypeToGetterMap[contractType], + bindings.TypeToGetterMap["link.chain.ton.ccip.Common"], + bindings.TypeToGetterMap[bindings.TypeOwnable], + } + for _, getterMap := range mapsToSearch { + getter, ok := getterMap[getterName] + if ok { + return getter, true + } + } + return nil, false +} + +func formatGetterResult(decoded any) string { + formatted, err := json.MarshalIndent(decoded, "", " ") + if err == nil { + return string(formatted) + } + return fmt.Sprintf("%+v", decoded) +} + +func isNoArgsInputType(t reflect.Type) bool { + if t == reflect.TypeOf(tvm.NoArgs{}) { + return true + } + + // Some bindings model no-args getters as Getter[struct{}, R]. + return t.Kind() == reflect.Struct && t.NumField() == 0 +} + +func selectGetterInteractive(cmd *cobra.Command, addr string, contractType string, options []string) (string, error) { + fd := int(os.Stdin.Fd()) + if !term.IsTerminal(fd) { + return "", errors.New("getter_name is required in non-interactive mode") + } + + out := cmd.OutOrStdout() + reader := bufio.NewReader(os.Stdin) + fmt.Fprintf(out, "Select getter for %s (%s):\n", addr, contractType) + for i, opt := range options { + fmt.Fprintf(out, " %d) %s\n", i+1, opt) + } + fmt.Fprintln(out, " 0) cancel") + + for { + fmt.Fprintf(out, "Enter number [0-%d]: ", len(options)) + line, err := reader.ReadString('\n') + if err != nil { + return "", fmt.Errorf("interactive selector failed: %w", err) + } + + choice := strings.TrimSpace(line) + index, convErr := strconv.Atoi(choice) + if convErr != nil || index < 0 || index > len(options) { + fmt.Fprintln(out, "Invalid selection, try again.") + continue + } + if index == 0 { + return "", errors.New("selection canceled") + } + + return options[index-1], nil + } +} diff --git a/pkg/ton/codec/debug/explorer/get_args.go b/pkg/ton/codec/debug/explorer/get_args.go new file mode 100644 index 000000000..c32def650 --- /dev/null +++ b/pkg/ton/codec/debug/explorer/get_args.go @@ -0,0 +1,469 @@ +package explorer + +import ( + "bufio" + "errors" + "fmt" + "math/big" + "os" + "reflect" + "sort" + "strconv" + "strings" + + "github.com/xssnick/tonutils-go/address" + "github.com/xssnick/tonutils-go/ton" + "golang.org/x/term" + + "github.com/smartcontractkit/chainlink-ton/pkg/bindings" + "github.com/smartcontractkit/chainlink-ton/pkg/ton/tvm" +) + +type getterArgSpec struct { + Name string + Type reflect.Type + FieldIndex int +} + +type getterDescriptor struct { + MethodName string + InputType reflect.Type + ArgSpecs []getterArgSpec + Decode func(*ton.ExecutionResult) (any, error) + Encode func(any) ([]any, error) +} + +func listGetterNames(contractType string) []string { + availableSet := make(map[string]struct{}) + collectGetterNames(availableSet, bindings.TypeToGetterMap[contractType]) + collectGetterNames(availableSet, bindings.TypeToGetterMap["link.chain.ton.ccip.Common"]) + collectGetterNames(availableSet, bindings.TypeToGetterMap[bindings.TypeOwnable]) + + available := make([]string, 0, len(availableSet)) + for name := range availableSet { + available = append(available, name) + } + sort.Strings(available) + return available +} + +func collectGetterNames(dest map[string]struct{}, getterMap bindings.GetterMap) { + for name := range getterMap { + dest[name] = struct{}{} + } +} + +func resolveGetterDescriptor(contractType string, getterName string) (getterDescriptor, error) { + getter, found := resolveRegisteredGetter(contractType, getterName) + if !found { + available := listGetterNames(contractType) + if len(available) == 0 { + return getterDescriptor{}, fmt.Errorf("no getters registered for contract type %q", contractType) + } + return getterDescriptor{}, fmt.Errorf("getter %q is not registered for %q (available: %s)", getterName, contractType, strings.Join(available, ", ")) + } + + desc, err := describeGetter(getter) + if err != nil { + return getterDescriptor{}, fmt.Errorf("unsupported getter %q: %w", getterName, err) + } + return desc, nil +} + +func describeGetter(getter any) (getterDescriptor, error) { + value := reflect.ValueOf(getter) + if value.Kind() != reflect.Struct { + return getterDescriptor{}, fmt.Errorf("registered getter has unsupported shape %T", getter) + } + + nameField := value.FieldByName("Name") + if !nameField.IsValid() || nameField.Kind() != reflect.String || nameField.String() == "" { + return getterDescriptor{}, fmt.Errorf("registered getter has invalid Name field %T", getter) + } + + decoderField := value.FieldByName("Decoder") + if !decoderField.IsValid() || decoderField.IsNil() { + return getterDescriptor{}, errors.New("getter has no decoder") + } + decodeMethod := decoderField.MethodByName("Decode") + if !decodeMethod.IsValid() { + return getterDescriptor{}, errors.New("getter decoder has no Decode method") + } + + encoderField := value.FieldByName("Encoder") + if !encoderField.IsValid() { + return getterDescriptor{}, errors.New("getter has no encoder information") + } + + inputType, err := inferGetterInputType(encoderField) + if err != nil { + return getterDescriptor{}, err + } + + encodeFn, err := buildEncodeFunction(encoderField) + if err != nil { + return getterDescriptor{}, err + } + + argSpecs := buildArgSpecs(inputType) + + decodeFn := func(result *ton.ExecutionResult) (any, error) { + outs := decodeMethod.Call([]reflect.Value{reflect.ValueOf(result)}) + if len(outs) != 2 { + return nil, errors.New("getter decoder returned unexpected values") + } + if !outs[1].IsNil() { + err, ok := outs[1].Interface().(error) + if !ok { + return nil, errors.New("getter decoder returned non-error second value") + } + return nil, err + } + return outs[0].Interface(), nil + } + + return getterDescriptor{ + MethodName: nameField.String(), + InputType: inputType, + ArgSpecs: argSpecs, + Decode: decodeFn, + Encode: encodeFn, + }, nil +} + +func inferGetterInputType(encoderField reflect.Value) (reflect.Type, error) { + encodeMethodType, err := findEncodeMethodType(encoderField) + if err != nil { + return nil, err + } + if encodeMethodType.NumIn() == 1 { + return encodeMethodType.In(0), nil + } + if encodeMethodType.NumIn() == 2 { + // Method signatures discovered from interface types include receiver as arg 0. + return encodeMethodType.In(1), nil + } + if encodeMethodType.NumIn() == 0 { + return nil, errors.New("getter encoder has unexpected Encode signature") + } + if encodeMethodType.NumIn() > 2 { + return nil, errors.New("getter encoder has unexpected Encode signature") + } + return nil, errors.New("getter encoder has unexpected Encode signature") +} + +func findEncodeMethodType(encoderField reflect.Value) (reflect.Type, error) { + if !encoderField.IsNil() { + encodeMethod := encoderField.MethodByName("Encode") + if !encodeMethod.IsValid() { + return nil, errors.New("getter encoder has no Encode method") + } + return encodeMethod.Type(), nil + } + + t := encoderField.Type() + m, ok := t.MethodByName("Encode") + if !ok { + return nil, errors.New("getter encoder type has no Encode method") + } + return m.Type, nil +} + +func buildEncodeFunction(encoderField reflect.Value) (func(any) ([]any, error), error) { + if !encoderField.IsNil() { + encodeMethod := encoderField.MethodByName("Encode") + if !encodeMethod.IsValid() { + return nil, errors.New("getter encoder has no Encode method") + } + + return func(input any) ([]any, error) { + outs := encodeMethod.Call([]reflect.Value{reflect.ValueOf(input)}) + if len(outs) != 2 { + return nil, errors.New("getter encoder returned unexpected values") + } + if !outs[1].IsNil() { + err, ok := outs[1].Interface().(error) + if !ok { + return nil, errors.New("getter encoder returned non-error second value") + } + return nil, err + } + params, ok := outs[0].Interface().([]any) + if !ok { + return nil, errors.New("getter encoder returned non-[]any params") + } + return params, nil + }, nil + } + + return func(input any) ([]any, error) { + return encodeArgsDefault(input) + }, nil +} + +func buildArgSpecs(inputType reflect.Type) []getterArgSpec { + if isNoArgsInputType(inputType) { + return nil + } + + if inputType.Kind() != reflect.Struct { + return []getterArgSpec{{Name: "arg1", Type: inputType, FieldIndex: -1}} + } + + out := make([]getterArgSpec, 0, inputType.NumField()) + for i := 0; i < inputType.NumField(); i++ { + field := inputType.Field(i) + if !field.IsExported() || field.Tag.Get("tvm") == "-" { + continue + } + out = append(out, getterArgSpec{Name: strings.ToLower(field.Name[:1]) + field.Name[1:], Type: field.Type, FieldIndex: i}) + } + return out +} + +func parseNamedArgs(argPairs []string) (map[string]string, error) { + result := make(map[string]string, len(argPairs)) + for _, pair := range argPairs { + name, value, ok := strings.Cut(pair, "=") + if !ok { + return nil, fmt.Errorf("invalid --arg %q, expected name=value", pair) + } + name = strings.TrimSpace(name) + if name == "" { + return nil, fmt.Errorf("invalid --arg %q, argument name is empty", pair) + } + if _, exists := result[name]; exists { + return nil, fmt.Errorf("duplicate named arg %q", name) + } + result[name] = value + } + return result, nil +} + +func buildGetterInput(desc getterDescriptor, positional []string, named map[string]string, interactive bool, cmdOut func(string) (string, error)) (any, error) { + if len(desc.ArgSpecs) == 0 { + return tvm.NoArgs{}, nil + } + + if len(desc.ArgSpecs) == 1 && desc.ArgSpecs[0].FieldIndex == -1 { + if len(positional) > 1 { + return nil, fmt.Errorf("too many positional args: expected 1, got %d", len(positional)) + } + if _, hasNamed := named[desc.ArgSpecs[0].Name]; hasNamed && len(positional) > 0 { + return nil, fmt.Errorf("arg %q provided as both positional and named", desc.ArgSpecs[0].Name) + } + + raw, err := resolveRawArgValue(desc.ArgSpecs[0], positional, named, 0, interactive, cmdOut) + if err != nil { + return nil, err + } + v, err := parseValueForType(raw, desc.ArgSpecs[0].Type) + if err != nil { + return nil, fmt.Errorf("arg 1 (%s): %w", desc.ArgSpecs[0].Name, err) + } + for name := range named { + if name != desc.ArgSpecs[0].Name { + return nil, fmt.Errorf("unknown named arg %q", name) + } + } + return v.Interface(), nil + } + + input := reflect.New(desc.InputType).Elem() + usedNamed := make(map[string]bool) + posIndex := 0 + for i, spec := range desc.ArgSpecs { + raw, err := resolveRawArgValue(spec, positional, named, posIndex, interactive, cmdOut) + if err != nil { + return nil, err + } + if _, ok := named[spec.Name]; ok { + usedNamed[spec.Name] = true + } else if posIndex < len(positional) { + posIndex++ + } + + v, err := parseValueForType(raw, spec.Type) + if err != nil { + return nil, fmt.Errorf("arg %d (%s): %w", i+1, spec.Name, err) + } + input.Field(spec.FieldIndex).Set(v) + } + + if posIndex < len(positional) { + return nil, fmt.Errorf("too many positional args: expected %d, got %d", len(desc.ArgSpecs), len(positional)) + } + + for name := range named { + if !usedNamed[name] { + return nil, fmt.Errorf("unknown named arg %q", name) + } + } + + return input.Interface(), nil +} + +func resolveRawArgValue(spec getterArgSpec, positional []string, named map[string]string, posIndex int, interactive bool, promptFn func(string) (string, error)) (string, error) { + if value, ok := named[spec.Name]; ok { + return value, nil + } + if posIndex < len(positional) { + return positional[posIndex], nil + } + if interactive { + return promptFn(fmt.Sprintf("Enter value for %s (%s) [or 0 to cancel]: ", spec.Name, spec.Type.String())) + } + return "", fmt.Errorf("missing required arg %q (%s)", spec.Name, spec.Type.String()) +} + +func promptForArgValue(prompt string) (string, error) { + if !term.IsTerminal(int(os.Stdin.Fd())) { + return "", errors.New("missing args in non-interactive mode") + } + fmt.Fprint(os.Stdout, prompt) + reader := bufio.NewReader(os.Stdin) + line, err := reader.ReadString('\n') + if err != nil { + return "", err + } + value := strings.TrimSpace(line) + if value == "0" { + return "", errors.New("selection canceled") + } + return value, nil +} + +func parseValueForType(raw string, t reflect.Type) (reflect.Value, error) { + if t == reflect.TypeOf(tvm.NoArgs{}) { + return reflect.ValueOf(tvm.NoArgs{}), nil + } + + if t.Kind() == reflect.Pointer { + if raw == "" { + return reflect.Zero(t), nil + } + v, err := parseValueForType(raw, t.Elem()) + if err != nil { + return reflect.Value{}, err + } + ptr := reflect.New(t.Elem()) + ptr.Elem().Set(v) + return ptr, nil + } + + if t == reflect.TypeOf(address.Address{}) { + addr, err := address.ParseAddr(raw) + if err != nil { + return reflect.Value{}, fmt.Errorf("expected address, got %q", raw) + } + return reflect.ValueOf(*addr), nil + } + + if t == reflect.TypeOf(big.Int{}) { + v, ok := new(big.Int).SetString(raw, 10) + if !ok { + return reflect.Value{}, fmt.Errorf("expected decimal big.Int, got %q", raw) + } + return reflect.ValueOf(*v), nil + } + + switch t.Kind() { + case reflect.String: + return reflect.ValueOf(raw).Convert(t), nil + case reflect.Bool: + b, err := strconv.ParseBool(raw) + if err != nil { + return reflect.Value{}, fmt.Errorf("expected bool, got %q", raw) + } + return reflect.ValueOf(b).Convert(t), nil + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + n, err := strconv.ParseInt(raw, 10, t.Bits()) + if err != nil { + return reflect.Value{}, fmt.Errorf("expected %s, got %q", t.String(), raw) + } + return reflect.ValueOf(n).Convert(t), nil + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + n, err := strconv.ParseUint(raw, 10, t.Bits()) + if err != nil { + return reflect.Value{}, fmt.Errorf("expected %s, got %q", t.String(), raw) + } + return reflect.ValueOf(n).Convert(t), nil + case reflect.Slice: + if t.Elem().Kind() == reflect.Uint8 { + hexInput := strings.TrimPrefix(raw, "0x") + decoded, decErr := decodeHexOrBytes(hexInput) + if decErr != nil { + return reflect.Value{}, fmt.Errorf("expected hex bytes, got %q", raw) + } + return reflect.ValueOf(decoded).Convert(t), nil + } + } + + return reflect.Value{}, fmt.Errorf("unsupported arg type %s", t.String()) +} + +func decodeHexOrBytes(raw string) ([]byte, error) { + if len(raw)%2 == 1 { + raw = "0" + raw + } + decoded := make([]byte, len(raw)/2) + for i := 0; i < len(decoded); i++ { + v, err := strconv.ParseUint(raw[2*i:2*i+2], 16, 8) + if err != nil { + return nil, err + } + decoded[i] = byte(v) + } + return decoded, nil +} + +// Mirrors tvm.encodeArgsDefault behavior for nil-encoder getters. +func encodeArgsDefault(input any) ([]any, error) { + if input == nil { + return []any{nil}, nil + } + + value := reflect.ValueOf(input) + if !value.IsValid() { + return nil, errors.New("cannot encode invalid argument value") + } + + switch value.Kind() { + case reflect.Interface: + if value.IsNil() { + return []any{nil}, nil + } + return encodeArgsDefault(value.Elem().Interface()) + case reflect.Pointer: + if value.IsNil() { + return []any{nil}, nil + } + return []any{value.Interface()}, nil + case reflect.Struct: + if value.Type() == reflect.TypeOf(tvm.NoArgs{}) { + return []any{}, nil + } + t := value.Type() + params := make([]any, 0, t.NumField()) + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + if !field.IsExported() || field.Tag.Get("tvm") == "-" { + continue + } + params = append(params, value.Field(i).Interface()) + } + return params, nil + case reflect.Slice, reflect.Array: + if value.Type().Elem().Kind() == reflect.Uint8 { + return []any{value.Interface()}, nil + } + length := value.Len() + params := make([]any, length) + for i := 0; i < length; i++ { + params[i] = value.Index(i).Interface() + } + return params, nil + default: + return []any{value.Interface()}, nil + } +} 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..ade2dcb03 --- /dev/null +++ b/pkg/ton/codec/debug/explorer/tx_lookup.go @@ -0,0 +1,311 @@ +package explorer + +import ( + "context" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "math/big" + "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 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 { + 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 := 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, 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/explorer/utils.go b/pkg/ton/codec/debug/explorer/utils.go index 2fd6449a7..c7ddc14ba 100644 --- a/pkg/ton/codec/debug/explorer/utils.go +++ b/pkg/ton/codec/debug/explorer/utils.go @@ -19,21 +19,31 @@ func ParseURL(urlStr string) (txHash, address, network string, err error) { network = "testnet" // default switch { case strings.Contains(u.Host, "testnet.tonscan.org"): + case strings.Contains(u.Host, "testnet.tonviewer.com"): network = "testnet" case strings.Contains(u.Host, "tonscan.org"): + case strings.Contains(u.Host, "tonviewer.com"): network = "mainnet" case strings.Contains(u.Host, "localhost"): network = "mylocalton" } // Handle tonscan.org transaction URLs: /tx/{hash} - if strings.Contains(u.Host, "tonscan.org") { + switch { + + case strings.Contains(u.Host, "tonscan.org"): pathParts := strings.Split(strings.Trim(u.Path, "/"), "/") if len(pathParts) >= 2 && pathParts[0] == "tx" { txHash = pathParts[1] return txHash, address, network, nil } - } else if strings.Contains(u.Host, "localhost") { + case strings.Contains(u.Host, "tonviewer.com"): + pathParts := strings.Split(strings.Trim(u.Path, "/"), "/") + if len(pathParts) >= 2 && pathParts[0] == "transaction" { + txHash = pathParts[1] + return txHash, address, network, nil + } + case strings.Contains(u.Host, "localhost"): // Handle mylocalton transaction URLs: /transaction?hash={hash}&account={address} if u.Path == "/transaction" { query := u.Query() diff --git a/pkg/ton/codec/debug/pretty_print.go b/pkg/ton/codec/debug/pretty_print.go index e2f864b56..4f0d124f0 100644 --- a/pkg/ton/codec/debug/pretty_print.go +++ b/pkg/ton/codec/debug/pretty_print.go @@ -14,8 +14,11 @@ import ( "github.com/smartcontractkit/chainlink-ton/pkg/ton/codec" "github.com/smartcontractkit/chainlink-ton/pkg/ton/codec/debug/decoders/ccip/ccipsendexecutor" "github.com/smartcontractkit/chainlink-ton/pkg/ton/codec/debug/decoders/ccip/feequoter" + merkle_root "github.com/smartcontractkit/chainlink-ton/pkg/ton/codec/debug/decoders/ccip/merkler_root" "github.com/smartcontractkit/chainlink-ton/pkg/ton/codec/debug/decoders/ccip/offramp" "github.com/smartcontractkit/chainlink-ton/pkg/ton/codec/debug/decoders/ccip/onramp" + "github.com/smartcontractkit/chainlink-ton/pkg/ton/codec/debug/decoders/ccip/receiveexecutor" + "github.com/smartcontractkit/chainlink-ton/pkg/ton/codec/debug/decoders/ccip/receiver" "github.com/smartcontractkit/chainlink-ton/pkg/ton/codec/debug/decoders/ccip/router" "github.com/smartcontractkit/chainlink-ton/pkg/ton/codec/debug/decoders/jetton/minter" "github.com/smartcontractkit/chainlink-ton/pkg/ton/codec/debug/decoders/jetton/wallet" @@ -77,8 +80,12 @@ func defaultDecoders() map[string]lib.ContractDecoder { // CCIP contract types maps.Copy(tlbs, router.TLBs) maps.Copy(tlbs, onramp.TLBs) + maps.Copy(tlbs, merkle_root.TLBs) maps.Copy(tlbs, feequoter.TLBs) maps.Copy(tlbs, ccipsendexecutor.TLBs) + maps.Copy(tlbs, receiveexecutor.TLBs) + // CCIP Test contract types + maps.Copy(tlbs, receiver.TLBs) // MCMS contract types maps.Copy(tlbs, rbac.TLBs) maps.Copy(tlbs, mcms.TLBs) @@ -89,9 +96,12 @@ func defaultDecoders() map[string]lib.ContractDecoder { registerDecoder(t, minter.NewDecoder(tlbs)) registerDecoder(t, router.NewDecoder(tlbs)) registerDecoder(t, onramp.NewDecoder(tlbs)) + registerDecoder(t, merkle_root.NewDecoder(tlbs)) registerDecoder(t, offramp.NewDecoder(tlbs)) registerDecoder(t, feequoter.NewDecoder(tlbs)) registerDecoder(t, ccipsendexecutor.NewDecoder(tlbs)) + registerDecoder(t, receiveexecutor.NewDecoder(tlbs)) + registerDecoder(t, receiver.NewDecoder(tlbs)) registerDecoder(t, rbac.NewDecoder(tlbs)) registerDecoder(t, mcms.NewDecoder(tlbs)) registerDecoder(t, timelock.NewDecoder(tlbs)) @@ -275,6 +285,20 @@ func (d DebuggerEnvironment) describeSentMessage(m *tt.SentMessage, verbose bool func (d DebuggerEnvironment) describeExternalOutMsg(m tt.OutgoingExternalMessages, verbose bool) (*lib.MessageInfo, error) { var info lib.MessageInfo var err error + if m.SrcAddr != nil { + if actor, ok := d.existingAddresses[m.SrcAddr.String()]; ok { + if contract, exists := d.contracts[actor.Type]; exists { + info, err = contract.EventInfo(m.DstAddr, m.Body) + if err == nil { + return &info, nil + } + if !errors.Is(err, codec.ErrUnknownMessage) { + return nil, err + } + } + } + } + for _, contract := range d.contracts { info, err = contract.EventInfo(m.DstAddr, m.Body) if err == 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