diff --git a/.changeset/khaki-geese-poke.md b/.changeset/khaki-geese-poke.md new file mode 100644 index 00000000..e3e1b7f9 --- /dev/null +++ b/.changeset/khaki-geese-poke.md @@ -0,0 +1,5 @@ +--- +"chainlink-deployments-framework": minor +--- + +Adds TON blockchain analyzer support diff --git a/experimental/analyzer/report_builder.go b/experimental/analyzer/report_builder.go index 71e68030..1c81ec11 100644 --- a/experimental/analyzer/report_builder.go +++ b/experimental/analyzer/report_builder.go @@ -25,33 +25,27 @@ func BuildProposalReport(ctx context.Context, proposalContext ProposalContext, e var calls []*DecodedCall switch family { case chainsel.FamilyEVM: - dec, err := AnalyzeEVMTransactions(ctx, proposalContext, env, chainSel, []types.Transaction{op.Transaction}) - if err != nil { - return nil, err - } - calls = dec + calls, err = AnalyzeEVMTransactions(ctx, proposalContext, env, chainSel, []types.Transaction{op.Transaction}) + case chainsel.FamilySolana: - dec, err := AnalyzeSolanaTransactions(proposalContext, chainSel, []types.Transaction{op.Transaction}) - if err != nil { - return nil, err - } - calls = dec + calls, err = AnalyzeSolanaTransactions(proposalContext, chainSel, []types.Transaction{op.Transaction}) + case chainsel.FamilyAptos: - dec, err := AnalyzeAptosTransactions(proposalContext, chainSel, []types.Transaction{op.Transaction}) - if err != nil { - return nil, err - } - calls = dec + calls, err = AnalyzeAptosTransactions(proposalContext, chainSel, []types.Transaction{op.Transaction}) + case chainsel.FamilySui: - dec, err := AnalyzeSuiTransactions(proposalContext, chainSel, []types.Transaction{op.Transaction}) - if err != nil { - return nil, err - } - calls = dec + calls, err = AnalyzeSuiTransactions(proposalContext, chainSel, []types.Transaction{op.Transaction}) + + case chainsel.FamilyTon: + calls, err = AnalyzeTONTransactions(proposalContext, []types.Transaction{op.Transaction}) default: calls = []*DecodedCall{} } + if err != nil { + return nil, err + } + rpt.Operations[i] = OperationReport{ ChainSelector: chainSel, ChainName: chainNameOrUnknown(chainName), @@ -128,6 +122,19 @@ func BuildTimelockReport(ctx context.Context, proposalCtx ProposalContext, env d Calls: []*DecodedCall{dec[j]}, } } + case chainsel.FamilyTon: + dec, err := AnalyzeTONTransactions(proposalCtx, batch.Transactions) + if err != nil { + return nil, err + } + for j := range dec { + ops[j] = OperationReport{ + ChainSelector: chainSel, + ChainName: chainNameOrUnknown(chainName), + Family: family, + Calls: []*DecodedCall{dec[j]}, + } + } default: for j := range batch.Transactions { ops[j] = OperationReport{ diff --git a/experimental/analyzer/report_builder_test.go b/experimental/analyzer/report_builder_test.go index 444f3de1..bc39bba9 100644 --- a/experimental/analyzer/report_builder_test.go +++ b/experimental/analyzer/report_builder_test.go @@ -195,33 +195,44 @@ func TestChainNameOrUnknown(t *testing.T) { require.Equal(t, "", chainNameOrUnknown(" ")) } -func TestBuildProposalReport_FamilyBranches(t *testing.T) { +func TestBuildProposalReport_FamilyErrors(t *testing.T) { t.Parallel() tests := []struct { - name string - selector uint64 - expectedError string + name string + selector uint64 + expectedMsg string + wantErr bool // if true, expect returned error; if false, error is in Method field (TON behavior) }{ { - name: "EVM_missing_registry", - selector: chainsel.ETHEREUM_TESTNET_SEPOLIA.Selector, - expectedError: "EVM registry is not available", + name: "EVM_missing_registry", + selector: chainsel.ETHEREUM_TESTNET_SEPOLIA.Selector, + expectedMsg: "EVM registry is not available", + wantErr: true, }, { - name: "Solana_missing_registry", - selector: chainsel.SOLANA_DEVNET.Selector, - expectedError: "failed to analyze solana transaction 0: solana decoder registry is not available", + name: "Solana_missing_registry", + selector: chainsel.SOLANA_DEVNET.Selector, + expectedMsg: "failed to analyze solana transaction 0: solana decoder registry is not available", + wantErr: true, }, { - name: "Aptos_unmarshal_additional_fields", - selector: chainsel.APTOS_TESTNET.Selector, - expectedError: "failed to unmarshal Aptos additional fields: unexpected end of JSON input", + name: "Aptos_unmarshal_additional_fields", + selector: chainsel.APTOS_TESTNET.Selector, + expectedMsg: "failed to unmarshal Aptos additional fields: unexpected end of JSON input", + wantErr: true, }, { - name: "Sui_unmarshal_additional_fields", - selector: chainsel.SUI_TESTNET.Selector, - expectedError: "failed to unmarshal Sui additional fields: unexpected end of JSON input", + name: "Sui_unmarshal_additional_fields", + selector: chainsel.SUI_TESTNET.Selector, + expectedMsg: "failed to unmarshal Sui additional fields: unexpected end of JSON input", + wantErr: true, + }, + { + name: "TON_decode_failure", + selector: chainsel.TON_TESTNET.Selector, + expectedMsg: "failed to decode TON transaction", + wantErr: false, // TON doesn't unmarshal AdditionalFields, so decode errors go to Method field }, } @@ -245,40 +256,56 @@ func TestBuildProposalReport_FamilyBranches(t *testing.T) { }, } - _, err := BuildProposalReport(t.Context(), ctx, deployment.Environment{}, proposal) - require.Error(t, err) - require.Contains(t, err.Error(), tt.expectedError) + report, err := BuildProposalReport(t.Context(), ctx, deployment.Environment{}, proposal) + if tt.wantErr { + require.Error(t, err) + require.Contains(t, err.Error(), tt.expectedMsg) + } else { + require.NoError(t, err) + require.Contains(t, report.Operations[0].Calls[0].Method, tt.expectedMsg) + } }) } } -func TestBuildTimelockReport_FamilyBranches(t *testing.T) { +func TestBuildTimelockReport_FamilyErrors(t *testing.T) { t.Parallel() tests := []struct { - name string - selector uint64 - expectedError string + name string + selector uint64 + expectedMsg string + wantErr bool // if true, expect returned error; if false, error is in Method field (TON behavior) }{ { - name: "EVM_missing_registry", - selector: chainsel.ETHEREUM_TESTNET_SEPOLIA.Selector, - expectedError: "EVM registry is not available", + name: "EVM_missing_registry", + selector: chainsel.ETHEREUM_TESTNET_SEPOLIA.Selector, + expectedMsg: "EVM registry is not available", + wantErr: true, }, { - name: "Solana_missing_registry", - selector: chainsel.SOLANA_DEVNET.Selector, - expectedError: "failed to analyze solana transaction 0: solana decoder registry is not available", + name: "Solana_missing_registry", + selector: chainsel.SOLANA_DEVNET.Selector, + expectedMsg: "failed to analyze solana transaction 0: solana decoder registry is not available", + wantErr: true, }, { - name: "Aptos_unmarshal_additional_fields", - selector: chainsel.APTOS_TESTNET.Selector, - expectedError: "failed to unmarshal Aptos additional fields: unexpected end of JSON input", + name: "Aptos_unmarshal_additional_fields", + selector: chainsel.APTOS_TESTNET.Selector, + expectedMsg: "failed to unmarshal Aptos additional fields: unexpected end of JSON input", + wantErr: true, }, { - name: "Sui_unmarshal_additional_fields", - selector: chainsel.SUI_TESTNET.Selector, - expectedError: "failed to unmarshal Sui additional fields: unexpected end of JSON input", + name: "Sui_unmarshal_additional_fields", + selector: chainsel.SUI_TESTNET.Selector, + expectedMsg: "failed to unmarshal Sui additional fields: unexpected end of JSON input", + wantErr: true, + }, + { + name: "TON_decode_failure", + selector: chainsel.TON_TESTNET.Selector, + expectedMsg: "failed to decode TON transaction", + wantErr: false, // TON doesn't unmarshal AdditionalFields, so decode errors go to Method field }, } @@ -302,9 +329,16 @@ func TestBuildTimelockReport_FamilyBranches(t *testing.T) { }, } - _, err := BuildTimelockReport(t.Context(), proposalCtx, deployment.Environment{}, proposal) - require.Error(t, err) - require.Contains(t, err.Error(), tt.expectedError) + report, err := BuildTimelockReport(t.Context(), proposalCtx, deployment.Environment{}, proposal) + if tt.wantErr { + require.Error(t, err) + require.Contains(t, err.Error(), tt.expectedMsg) + } else { + require.NoError(t, err) + for _, op := range report.Batches[0].Operations { + require.Contains(t, op.Calls[0].Method, tt.expectedMsg) + } + } }) } } @@ -372,14 +406,14 @@ func TestBuildProposalReport_DefaultCase(t *testing.T) { renderer: NewMarkdownRenderer(), } - // Use a TON chain selector - TON family is not handled in the switch statement + // Use a TRON chain selector - TRON family is not handled in the switch statement // so it will trigger the default case - tonChainSelector := chainsel.TON_LOCALNET.Selector + tronChainSelector := chainsel.TRON_DEVNET.Selector proposal := &mcms.Proposal{ Operations: []types.Operation{ { - ChainSelector: types.ChainSelector(tonChainSelector), + ChainSelector: types.ChainSelector(tronChainSelector), Transaction: types.Transaction{ To: "0x1234567890123456789012345678901234567890", Data: []byte{0x01, 0x02, 0x03, 0x04}, @@ -395,8 +429,8 @@ func TestBuildProposalReport_DefaultCase(t *testing.T) { require.Len(t, report.Operations, 1) operation := report.Operations[0] - require.Equal(t, tonChainSelector, operation.ChainSelector) - require.Equal(t, "ton-localnet", operation.ChainName) // TON chain has a known name - require.Equal(t, "ton", operation.Family) // TON family - require.Empty(t, operation.Calls) // Default case sets calls to empty slice + require.Equal(t, tronChainSelector, operation.ChainSelector) + require.Equal(t, chainsel.TRON_DEVNET.Name, operation.ChainName) // TRON chain has a known name + require.Equal(t, chainsel.FamilyTron, operation.Family) // TRON family + require.Empty(t, operation.Calls) // Default case sets calls to empty slice } diff --git a/experimental/analyzer/sui_analyzer_test.go b/experimental/analyzer/sui_analyzer_test.go index 6259825a..ead38276 100644 --- a/experimental/analyzer/sui_analyzer_test.go +++ b/experimental/analyzer/sui_analyzer_test.go @@ -4,10 +4,11 @@ import ( "encoding/json" "testing" + "github.com/stretchr/testify/require" + chainsel "github.com/smartcontractkit/chain-selectors" mcmssuisdk "github.com/smartcontractkit/mcms/sdk/sui" "github.com/smartcontractkit/mcms/types" - "github.com/stretchr/testify/require" "github.com/smartcontractkit/chainlink-deployments-framework/deployment" ) diff --git a/experimental/analyzer/ton_analyzer.go b/experimental/analyzer/ton_analyzer.go new file mode 100644 index 00000000..d43ec236 --- /dev/null +++ b/experimental/analyzer/ton_analyzer.go @@ -0,0 +1,60 @@ +package analyzer + +import ( + "fmt" + + "github.com/smartcontractkit/chainlink-ton/pkg/bindings" + "github.com/smartcontractkit/mcms/sdk" + "github.com/smartcontractkit/mcms/sdk/ton" + "github.com/smartcontractkit/mcms/types" +) + +// AnalyzeTONTransactions decodes a slice of TON transactions and returns their decoded representations. +func AnalyzeTONTransactions(ctx ProposalContext, txs []types.Transaction) ([]*DecodedCall, error) { + decoder := ton.NewDecoder(bindings.Registry) + decodedTxs := make([]*DecodedCall, len(txs)) + for i, op := range txs { + analyzedTransaction, err := AnalyzeTONTransaction(ctx, decoder, op) + if err != nil { + return nil, fmt.Errorf("failed to analyze TON transaction %d: %w", i, err) + } + decodedTxs[i] = analyzedTransaction + } + + return decodedTxs, nil +} + +// AnalyzeTONTransaction decodes a single TON transaction using the MCMS TON decoder. +// +// Unlike Aptos/Sui analyzers, this function does not unmarshal AdditionalFields because +// the TON decoder only requires tx.Data (BOC cell) and tx.ContractType (metadata). +// AdditionalFields in TON is only used by the encoder/timelock_converter for the Value field. +// +// On decode failure, this function returns a DecodedCall with the error in the Method field +// instead of returning an error. This allows the proposal to continue processing even if +// a single transaction fails to decode. +func AnalyzeTONTransaction(_ ProposalContext, decoder sdk.Decoder, mcmsTx types.Transaction) (*DecodedCall, error) { + decodedOp, err := decoder.Decode(mcmsTx, mcmsTx.ContractType) + if err != nil { + // Don't return an error to not block the whole proposal decoding because of a single transaction decode failure. + // Instead, put the error message in the Method field so it's visible in the report. + errStr := fmt.Errorf("failed to decode TON transaction: %w", err) + + return &DecodedCall{ + Address: mcmsTx.To, + Method: errStr.Error(), + }, nil + } + + namedArgs, err := toNamedFields(decodedOp) + if err != nil { + return nil, fmt.Errorf("failed to convert decoded operation to named arguments: %w", err) + } + + return &DecodedCall{ + Address: mcmsTx.To, + Method: decodedOp.MethodName(), + Inputs: namedArgs, + Outputs: []NamedField{}, + }, nil +} diff --git a/experimental/analyzer/ton_analyzer_test.go b/experimental/analyzer/ton_analyzer_test.go new file mode 100644 index 00000000..d2f582a6 --- /dev/null +++ b/experimental/analyzer/ton_analyzer_test.go @@ -0,0 +1,242 @@ +package analyzer + +import ( + "encoding/json" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/crypto" + "github.com/smartcontractkit/chainlink-ton/pkg/bindings" + "github.com/smartcontractkit/chainlink-ton/pkg/bindings/lib/access/rbac" + "github.com/smartcontractkit/chainlink-ton/pkg/ton/tlbe" + "github.com/smartcontractkit/mcms/sdk/ton" + "github.com/smartcontractkit/mcms/types" + "github.com/stretchr/testify/require" + "github.com/xssnick/tonutils-go/address" + "github.com/xssnick/tonutils-go/tlb" + "github.com/xssnick/tonutils-go/tvm/cell" +) + +const testAddress = "EQDtFpEwcFAEcRe5mLVh2N6C0x-_hJEM7W61_JLnSF74p4q2" + +func TestAnalyzeTONTransaction(t *testing.T) { + t.Parallel() + + setup := newTestTONSetup(t) + ctx := &DefaultProposalContext{} + decoder := ton.NewDecoder(bindings.Registry) + + tests := []struct { + name string + mcmsTx types.Transaction + want *DecodedCall + wantErrContain string + }{ + { + name: "success - RBAC GrantRole", + mcmsTx: setup.makeGrantRoleTx(t, 1), + want: setup.expectedGrantRoleCall(1), + }, + { + name: "invalid data", + mcmsTx: makeInvalidTx("com.chainlink.ton.mcms.MCMS"), + want: &DecodedCall{Address: testAddress}, + wantErrContain: "invalid cell BOC data", + }, + { + name: "unknown contract type", + mcmsTx: types.Transaction{ + OperationMetadata: types.OperationMetadata{ContractType: "unknown.type"}, + To: testAddress, + Data: []byte{0x01, 0x02}, + AdditionalFields: json.RawMessage(`{"value":0}`), + }, + want: &DecodedCall{Address: testAddress}, + wantErrContain: "unknown contract interface: unknown.type", + }, + { + name: "empty data", + mcmsTx: types.Transaction{ + OperationMetadata: types.OperationMetadata{ContractType: "com.chainlink.ton.mcms.MCMS"}, + To: testAddress, + Data: []byte{}, + AdditionalFields: json.RawMessage(`{"value":0}`), + }, + want: &DecodedCall{Address: testAddress}, + wantErrContain: "invalid cell BOC data", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + result, err := AnalyzeTONTransaction(ctx, decoder, tt.mcmsTx) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, tt.want.Address, result.Address) + + if tt.wantErrContain != "" { + require.Contains(t, result.Method, tt.wantErrContain) + require.Nil(t, result.Inputs) + + return + } + + require.Equal(t, tt.want, result) + }) + } +} + +// testTONSetup contains common test fixtures for TON analyzer tests. +type testTONSetup struct { + targetAddr *address.Address + exampleRoleBig *big.Int +} + +func TestAnalyzeTONTransactions(t *testing.T) { + t.Parallel() + + setup := newTestTONSetup(t) + ctx := &DefaultProposalContext{} + + tests := []struct { + name string + txs []types.Transaction + want []*DecodedCall + wantErrContains []string + }{ + { + name: "multiple valid transactions", + txs: []types.Transaction{ + setup.makeGrantRoleTx(t, 1), + setup.makeGrantRoleTx(t, 2), + setup.makeGrantRoleTx(t, 3), + }, + want: []*DecodedCall{ + setup.expectedGrantRoleCall(1), + setup.expectedGrantRoleCall(2), + setup.expectedGrantRoleCall(3), + }, + }, + { + name: "mixed valid and invalid", + txs: []types.Transaction{ + makeInvalidTx("com.chainlink.ton.mcms.MCMS"), + setup.makeGrantRoleTx(t, 1), + makeInvalidTx("com.chainlink.ton.mcms.Timelock"), + }, + want: []*DecodedCall{ + {Address: testAddress}, + setup.expectedGrantRoleCall(1), + {Address: testAddress}, + }, + wantErrContains: []string{"invalid cell BOC data", "", "invalid cell BOC data"}, + }, + { + name: "all decode failures", + txs: []types.Transaction{ + makeInvalidTx("com.chainlink.ton.mcms.MCMS"), + makeInvalidTx("com.chainlink.ton.mcms.Timelock"), + }, + want: []*DecodedCall{ + {Address: testAddress}, + {Address: testAddress}, + }, + wantErrContains: []string{"invalid cell BOC data", "invalid cell BOC data"}, + }, + { + name: "empty list", + txs: []types.Transaction{}, + want: []*DecodedCall{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + results, err := AnalyzeTONTransactions(ctx, tt.txs) + require.NoError(t, err) + require.Len(t, results, len(tt.want)) + + for i, result := range results { + require.Equal(t, tt.want[i].Address, result.Address, "call %d", i) + + if len(tt.wantErrContains) > i && tt.wantErrContains[i] != "" { + require.Contains(t, result.Method, tt.wantErrContains[i], "call %d", i) + require.Nil(t, result.Inputs, "call %d", i) + + continue + } + + require.Equal(t, tt.want[i], result) + } + }) + } +} + +func newTestTONSetup(t *testing.T) *testTONSetup { + t.Helper() + + exampleRole := crypto.Keccak256Hash([]byte("EXAMPLE_ROLE")) + exampleRoleBig, _ := cell.BeginCell(). + MustStoreBigInt(new(big.Int).SetBytes(exampleRole[:]), 257). + EndCell(). + ToBuilder(). + ToSlice(). + LoadBigInt(256) + + return &testTONSetup{ + targetAddr: address.MustParseAddr("EQADa3W6G0nSiTV4a6euRA42fU9QxSEnb-WeDpcrtWzA2jM8"), + exampleRoleBig: exampleRoleBig, + } +} + +func (s *testTONSetup) makeGrantRoleTx(t *testing.T, queryID uint64) types.Transaction { + t.Helper() + + grantRoleData, err := tlb.ToCell(rbac.GrantRole{ + QueryID: queryID, + Role: tlbe.NewUint256(s.exampleRoleBig), + Account: s.targetAddr, + }) + require.NoError(t, err) + + tx, err := ton.NewTransaction( + s.targetAddr, + grantRoleData.ToBuilder().ToSlice(), + big.NewInt(0), + "com.chainlink.ton.lib.access.RBAC", + []string{"grantRole"}, + ) + require.NoError(t, err) + + return tx +} + +func (s *testTONSetup) expectedGrantRoleCall(queryID uint64) *DecodedCall { + return &DecodedCall{ + Address: s.targetAddr.String(), + Method: "com.chainlink.ton.lib.access.RBAC::GrantRole(0x0)", + Inputs: []NamedField{ + {Name: "QueryID", Value: SimpleField{Value: bigIntStr(queryID)}}, + {Name: "Role", Value: SimpleField{Value: s.exampleRoleBig.String()}}, + {Name: "Account", Value: SimpleField{Value: s.targetAddr.String()}}, + }, + Outputs: []NamedField{}, + } +} + +func bigIntStr(v uint64) string { + return new(big.Int).SetUint64(v).String() +} + +func makeInvalidTx(contractType string) types.Transaction { + return types.Transaction{ + OperationMetadata: types.OperationMetadata{ContractType: contractType}, + To: testAddress, + Data: []byte{0xFF, 0xFF}, + AdditionalFields: json.RawMessage(`{"value":0}`), + } +} diff --git a/experimental/analyzer/upf/timelock_checker.go b/experimental/analyzer/upf/timelock_checker.go new file mode 100644 index 00000000..c0d94cb7 --- /dev/null +++ b/experimental/analyzer/upf/timelock_checker.go @@ -0,0 +1,66 @@ +package upf + +import "strings" + +// timelockBatchChecker provides chain-specific logic for detecting timelock batch functions. +type timelockBatchChecker interface { + isTimelockBatch(functionName string) bool +} + +// evmTimelockChecker handles EVM chains. +// Matches full function signatures for scheduleBatch and bypasserExecuteBatch. +type evmTimelockChecker struct{} + +func (evmTimelockChecker) isTimelockBatch(functionName string) bool { + return functionName == "function scheduleBatch((address,uint256,bytes)[] calls, bytes32 predecessor, bytes32 salt, uint256 delay) returns()" || + functionName == "function bypasserExecuteBatch((address,uint256,bytes)[] calls) payable returns()" +} + +// solanaTimelockChecker handles Solana chain. +// Matches exact function names: ScheduleBatch, BypasserExecuteBatch. +type solanaTimelockChecker struct{} + +func (solanaTimelockChecker) isTimelockBatch(functionName string) bool { + return functionName == "ScheduleBatch" || functionName == "BypasserExecuteBatch" +} + +// suiAptosTimelockChecker handles both Sui and Aptos chains. +// Sui: mcms::timelock_schedule_batch, mcms::timelock_bypasser_execute_batch +// Aptos: package::module::timelock_schedule_batch, package::module::timelock_bypasser_execute_batch +// Uses HasSuffix to prevent false positives like "::timelock_schedule_batch_helper". +type suiAptosTimelockChecker struct{} + +func (suiAptosTimelockChecker) isTimelockBatch(functionName string) bool { + return strings.HasSuffix(functionName, "::timelock_schedule_batch") || + strings.HasSuffix(functionName, "::timelock_bypasser_execute_batch") +} + +// tonTimelockChecker handles TON chain. +// TON: ContractType::ScheduleBatch(0x...), ContractType::BypasserExecuteBatch(0x...) +// Uses Contains because the opcode suffix (0x...) varies. +type tonTimelockChecker struct{} + +func (tonTimelockChecker) isTimelockBatch(functionName string) bool { + return strings.Contains(functionName, "::ScheduleBatch(") || + strings.Contains(functionName, "::BypasserExecuteBatch(") +} + +// timelockBatchCheckers is a list of chain-specific checkers for timelock batch functions. +var timelockBatchCheckers = []timelockBatchChecker{ + evmTimelockChecker{}, + solanaTimelockChecker{}, + suiAptosTimelockChecker{}, + tonTimelockChecker{}, +} + +// isTimelockBatchFunction checks if the function name corresponds to a timelock batch operation +// across different chain families (EVM, Solana, Sui, Aptos, TON). +func isTimelockBatchFunction(functionName string) bool { + for _, checker := range timelockBatchCheckers { + if checker.isTimelockBatch(functionName) { + return true + } + } + + return false +} diff --git a/experimental/analyzer/upf/upf.go b/experimental/analyzer/upf/upf.go index 337b3506..9cf249f0 100644 --- a/experimental/analyzer/upf/upf.go +++ b/experimental/analyzer/upf/upf.go @@ -11,9 +11,11 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/goccy/go-yaml" chainsel "github.com/smartcontractkit/chain-selectors" + "github.com/smartcontractkit/chainlink-ton/pkg/bindings" "github.com/smartcontractkit/mcms" mcmsaptossdk "github.com/smartcontractkit/mcms/sdk/aptos" mcmssuisdk "github.com/smartcontractkit/mcms/sdk/sui" + mcmstonsdk "github.com/smartcontractkit/mcms/sdk/ton" mcmstypes "github.com/smartcontractkit/mcms/types" "github.com/smartcontractkit/chainlink-deployments-framework/deployment" @@ -44,11 +46,7 @@ func UpfConvertTimelockProposal( if batch.Metadata == nil || batch.Metadata.DecodedCalldata == nil { continue } - if batch.Metadata.ContractType == "RBACTimelock" && - (batch.Metadata.DecodedCalldata.FunctionName == "function scheduleBatch((address,uint256,bytes)[] calls, bytes32 predecessor, bytes32 salt, uint256 delay) returns()" || - batch.Metadata.DecodedCalldata.FunctionName == "function bypasserExecuteBatch((address,uint256,bytes)[] calls) payable returns()" || - batch.Metadata.DecodedCalldata.FunctionName == "BypasserExecuteBatch" || - batch.Metadata.DecodedCalldata.FunctionName == "ScheduleBatch") { + if batch.Metadata.ContractType == "RBACTimelock" && isTimelockBatchFunction(batch.Metadata.DecodedCalldata.FunctionName) { batch.Metadata.DecodedCalldata.FunctionArgs["calls"] = decodedBatches[decodedBatchesIndex] decodedBatchesIndex++ } @@ -239,14 +237,10 @@ func encodeTransactionData(mcmsOp mcmstypes.Operation) (string, error) { } switch chainFamily { - case chainsel.FamilySolana: - return base64.StdEncoding.EncodeToString(mcmsOp.Transaction.Data), nil - case chainsel.FamilyAptos: - return base64.StdEncoding.EncodeToString(mcmsOp.Transaction.Data), nil - case chainsel.FamilySui: - return base64.StdEncoding.EncodeToString(mcmsOp.Transaction.Data), nil - default: + case chainsel.FamilyEVM: return "0x" + hex.EncodeToString(mcmsOp.Transaction.Data), nil + default: + return base64.StdEncoding.EncodeToString(mcmsOp.Transaction.Data), nil } } @@ -261,55 +255,22 @@ func batchOperationsToUpfDecodedCalls(ctx context.Context, proposalContext mcmsa } decodedCalls[batchIdx] = make([]*DecodedInnerCall, len(batch.Transactions)) - + var describedTxs []*mcmsanalyzer.DecodedCall switch family { case chainsel.FamilyEVM: - describedTxs, err := mcmsanalyzer.AnalyzeEVMTransactions(ctx, proposalContext, env, chainSel, batch.Transactions) - if err != nil { - return nil, err - } - for callIdx, tx := range describedTxs { - decodedCalls[batchIdx][callIdx] = &DecodedInnerCall{ - To: tx.Address, - Data: cldDecodedCallToUpfDecodedCallData(tx), - } - } + describedTxs, err = mcmsanalyzer.AnalyzeEVMTransactions(ctx, proposalContext, env, chainSel, batch.Transactions) case chainsel.FamilySolana: - describedTxs, err := mcmsanalyzer.AnalyzeSolanaTransactions(proposalContext, chainSel, batch.Transactions) - if err != nil { - return nil, err - } - for callIdx, tx := range describedTxs { - decodedCalls[batchIdx][callIdx] = &DecodedInnerCall{ - To: tx.Address, - Data: cldDecodedCallToUpfDecodedCallData(tx), - } - } + describedTxs, err = mcmsanalyzer.AnalyzeSolanaTransactions(proposalContext, chainSel, batch.Transactions) case chainsel.FamilyAptos: - describedTxs, err := mcmsanalyzer.AnalyzeAptosTransactions(proposalContext, chainSel, batch.Transactions) - if err != nil { - return nil, err - } - for callIdx, tx := range describedTxs { - decodedCalls[batchIdx][callIdx] = &DecodedInnerCall{ - To: tx.Address, - Data: cldDecodedCallToUpfDecodedCallData(tx), - } - } + describedTxs, err = mcmsanalyzer.AnalyzeAptosTransactions(proposalContext, chainSel, batch.Transactions) case chainsel.FamilySui: - describedTxs, err := mcmsanalyzer.AnalyzeSuiTransactions(proposalContext, chainSel, batch.Transactions) - if err != nil { - return nil, err - } - for callIdx, tx := range describedTxs { - decodedCalls[batchIdx][callIdx] = &DecodedInnerCall{ - To: tx.Address, - Data: cldDecodedCallToUpfDecodedCallData(tx), - } - } + describedTxs, err = mcmsanalyzer.AnalyzeSuiTransactions(proposalContext, chainSel, batch.Transactions) + + case chainsel.FamilyTon: + describedTxs, err = mcmsanalyzer.AnalyzeTONTransactions(proposalContext, batch.Transactions) default: for callIdx, mcmsTx := range batch.Transactions { @@ -318,6 +279,19 @@ func batchOperationsToUpfDecodedCalls(ctx context.Context, proposalContext mcmsa Data: &DecodedCallData{FunctionName: family + " transaction decoding is not supported"}, } } + + continue + } + + if err != nil { + return nil, err + } + + for callIdx, tx := range describedTxs { + decodedCalls[batchIdx][callIdx] = &DecodedInnerCall{ + To: tx.Address, + Data: cldDecodedCallToUpfDecodedCallData(tx), + } } } @@ -379,6 +353,14 @@ func analyzeTransaction( return analyzeResult, "", nil + case chainsel.FamilyTon: + decoder := mcmstonsdk.NewDecoder(bindings.Registry) + analyzeResult, err := mcmsanalyzer.AnalyzeTONTransaction(proposalCtx, decoder, mcmsOp.Transaction) + if err != nil { + return nil, "", err + } + + return analyzeResult, "", nil default: return nil, "", fmt.Errorf("unsupported chain family: %s", chainFamily) } diff --git a/experimental/analyzer/upf/upf_test.go b/experimental/analyzer/upf/upf_test.go index 9953d8b2..df6447cd 100644 --- a/experimental/analyzer/upf/upf_test.go +++ b/experimental/analyzer/upf/upf_test.go @@ -2,24 +2,31 @@ package upf import ( "context" + "encoding/json" "fmt" + "math/big" "strings" "testing" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" "github.com/google/go-cmp/cmp" + chainsel "github.com/smartcontractkit/chain-selectors" "github.com/smartcontractkit/chainlink-ccip/chains/evm/gobindings/generated/v1_6_0/rmn_remote" rmnremotebindings "github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings/v0_1_0/rmn_remote" timelockbindings "github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings/v0_1_0/timelock" - "github.com/stretchr/testify/require" - - chainsel "github.com/smartcontractkit/chain-selectors" + "github.com/smartcontractkit/chainlink-ton/pkg/bindings/lib/access/rbac" + "github.com/smartcontractkit/chainlink-ton/pkg/ton/tlbe" "github.com/smartcontractkit/mcms" mcmssdk "github.com/smartcontractkit/mcms/sdk" mcmsevmsdk "github.com/smartcontractkit/mcms/sdk/evm" mcmssolanasdk "github.com/smartcontractkit/mcms/sdk/solana" mcmssuisdk "github.com/smartcontractkit/mcms/sdk/sui" + mcmstonsdk "github.com/smartcontractkit/mcms/sdk/ton" mcmstypes "github.com/smartcontractkit/mcms/types" + "github.com/stretchr/testify/require" + "github.com/xssnick/tonutils-go/address" + "github.com/xssnick/tonutils-go/tlb" "github.com/smartcontractkit/chainlink-deployments-framework/datastore" "github.com/smartcontractkit/chainlink-deployments-framework/deployment" @@ -111,6 +118,128 @@ func TestUpfConvertTimelockProposal(t *testing.T) { } } +func TestUpfConvertTimelockProposalWithSui(t *testing.T) { + t.Parallel() + ds := datastore.NewMemoryDataStore() + + // ---- Sui: testnet + dsAddContract(t, ds, chainsel.SUI_TESTNET.Selector, "0x4e825a4758064df713762e431c3a16b8105857195214469db0d6985b7d70266d", "MCMSUser 1.0.0") + + env := deployment.Environment{ + DataStore: ds.Seal(), + ExistingAddresses: deployment.NewMemoryAddressBook(), + } + + proposalCtx, err := mcmsanalyzer.NewDefaultProposalContext(env) + require.NoError(t, err) + + tests := []struct { + name string + timelockProposal string + signers map[mcmstypes.ChainSelector][]common.Address + assertion func(*testing.T, string, error) + }{ + { + name: "Sui proposal with valid transaction", + timelockProposal: timelockProposalSui, + signers: map[mcmstypes.ChainSelector][]common.Address{ + mcmstypes.ChainSelector(chainsel.SUI_TESTNET.Selector): { + common.HexToAddress("0xA5D5B0B844c8f11B61F28AC98BBA84dEA9b80953"), + }, + }, + assertion: func(t *testing.T, gotUpf string, err error) { + t.Helper() + require.NoError(t, err) + require.Equal(t, upfProposalSui, gotUpf) + }, + }, + { + name: "Sui proposal with unknown module", + timelockProposal: timelockProposalSuiUnknownModule, + signers: map[mcmstypes.ChainSelector][]common.Address{ + mcmstypes.ChainSelector(chainsel.SUI_TESTNET.Selector): { + common.HexToAddress("0xA5D5B0B844c8f11B61F28AC98BBA84dEA9b80953"), + }, + }, + assertion: func(t *testing.T, gotUpf string, err error) { + t.Helper() + require.NoError(t, err) + require.Equal(t, upfProposalSuiUnknownModule, gotUpf) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + timelockProposal, err := mcms.NewTimelockProposal(strings.NewReader(tt.timelockProposal)) + require.NoError(t, err) + mcmProposal := convertTimelockProposal(t.Context(), t, timelockProposal) + + got, err := UpfConvertTimelockProposal(t.Context(), proposalCtx, env, timelockProposal, mcmProposal, tt.signers) + + tt.assertion(t, got, err) + }) + } +} + +func TestUpfConvertTimelockProposalWithTon(t *testing.T) { + t.Parallel() + ds := datastore.NewMemoryDataStore() + + // ---- TON: testnet + dsAddContract(t, ds, chainsel.TON_TESTNET.Selector, "EQADa3W6G0nSiTV4a6euRA42fU9QxSEnb-WeDpcrtWzA2jM8", "MCMS 1.0.0") + + env := deployment.Environment{ + DataStore: ds.Seal(), + ExistingAddresses: deployment.NewMemoryAddressBook(), + } + + proposalCtx, err := mcmsanalyzer.NewDefaultProposalContext(env) + require.NoError(t, err) + + tests := []struct { + name string + timelockProposal string + signers map[mcmstypes.ChainSelector][]common.Address + assertion func(*testing.T, string, error) + }{ + { + name: "TON proposal with GrantRole transaction", + timelockProposal: timelockProposalTON(t), + signers: map[mcmstypes.ChainSelector][]common.Address{ + mcmstypes.ChainSelector(chainsel.TON_TESTNET.Selector): { + common.HexToAddress("0xA5D5B0B844c8f11B61F28AC98BBA84dEA9b80953"), + }, + }, + assertion: func(t *testing.T, gotUpf string, err error) { + t.Helper() + require.NoError(t, err) + // Verify it contains TON-specific content + require.Contains(t, gotUpf, "chainFamily: ton") + require.Contains(t, gotUpf, "chainName: ton-testnet") + require.Contains(t, gotUpf, "msigAddress: EQADa3W6G0nSiTV4a6euRA42fU9QxSEnb-WeDpcrtWzA2jM8") + require.Contains(t, gotUpf, "contractType: RBACTimelock") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + timelockProposal, err := mcms.NewTimelockProposal(strings.NewReader(tt.timelockProposal)) + require.NoError(t, err) + mcmProposal := convertTimelockProposal(t.Context(), t, timelockProposal) + + got, err := UpfConvertTimelockProposal(t.Context(), proposalCtx, env, timelockProposal, mcmProposal, tt.signers) + + tt.assertion(t, got, err) + }) + } +} + // ----- helpers ----- func convertTimelockProposal(ctx context.Context, t *testing.T, timelockProposal *mcms.TimelockProposal) *mcms.Proposal { @@ -130,6 +259,8 @@ func convertTimelockProposal(ctx context.Context, t *testing.T, timelockProposal converter, err := mcmssuisdk.NewTimelockConverter() require.NoError(t, err) converters[chain] = converter + case chainsel.FamilyTon: + converters[chain] = mcmstonsdk.NewTimelockConverter(mcmstonsdk.DefaultSendAmount) default: t.Fatalf("unsupported chain family %s", chainFamily) } @@ -595,68 +726,144 @@ signers: - "0xA5D5B0B844c8f11B61F28AC98BBA84dEA9b80953" ` -func TestUpfConvertTimelockProposalWithSui(t *testing.T) { - t.Parallel() - ds := datastore.NewMemoryDataStore() +// timelockProposalTON is generated using makeTONGrantRoleTx helper +var timelockProposalTON = func(t *testing.T) string { + t.Helper() + // Create a GrantRole transaction for the test + targetAddr := address.MustParseAddr("EQADa3W6G0nSiTV4a6euRA42fU9QxSEnb-WeDpcrtWzA2jM8") + exampleRole := crypto.Keccak256Hash([]byte("EXAMPLE_ROLE")) + grantRoleData, _ := tlb.ToCell(rbac.GrantRole{ + QueryID: 1, + Role: tlbe.NewUint256(new(big.Int).SetBytes(exampleRole[:])), + Account: targetAddr, + }) + + tx, _ := mcmstonsdk.NewTransaction( + targetAddr, + grantRoleData.ToBuilder().ToSlice(), + big.NewInt(0), + "com.chainlink.ton.lib.access.RBAC", + []string{"grantRole"}, + ) - // ---- Sui: testnet - dsAddContract(t, ds, chainsel.SUI_TESTNET.Selector, "0x4e825a4758064df713762e431c3a16b8105857195214469db0d6985b7d70266d", "MCMSUser 1.0.0") + // Marshal the transaction data + txData, err := json.Marshal(tx) + require.NoError(t, err) - env := deployment.Environment{ - DataStore: ds.Seal(), - ExistingAddresses: deployment.NewMemoryAddressBook(), - } + return fmt.Sprintf(`{ + "version": "v1", + "kind": "TimelockProposal", + "validUntil": 1999999999, + "signatures": [], + "overridePreviousRoot": false, + "chainMetadata": { + "1399300952838017768": { + "startingOpCount": 1, + "mcmAddress": "EQADa3W6G0nSiTV4a6euRA42fU9QxSEnb-WeDpcrtWzA2jM8", + "additionalFields": null + } + }, + "description": "simple TON proposal with GrantRole", + "action": "schedule", + "delay": "5m0s", + "timelockAddresses": { + "1399300952838017768": "EQADa3W6G0nSiTV4a6euRA42fU9QxSEnb-WeDpcrtWzA2jM8" + }, + "operations": [ + { + "chainSelector": 1399300952838017768, + "transactions": [%s] + } + ] +}`, string(txData)) +} - proposalCtx, err := mcmsanalyzer.NewDefaultProposalContext(env) - require.NoError(t, err) +func TestIsTimelockBatchFunction(t *testing.T) { + t.Parallel() tests := []struct { - name string - timelockProposal string - signers map[mcmstypes.ChainSelector][]common.Address - assertion func(*testing.T, string, error) + name string + functionName string + want bool }{ + // EVM { - name: "Sui proposal with valid transaction", - timelockProposal: timelockProposalSui, - signers: map[mcmstypes.ChainSelector][]common.Address{ - mcmstypes.ChainSelector(chainsel.SUI_TESTNET.Selector): { - common.HexToAddress("0xA5D5B0B844c8f11B61F28AC98BBA84dEA9b80953"), - }, - }, - assertion: func(t *testing.T, gotUpf string, err error) { - t.Helper() - require.NoError(t, err) - require.Equal(t, upfProposalSui, gotUpf) - }, + name: "EVM scheduleBatch", + functionName: "function scheduleBatch((address,uint256,bytes)[] calls, bytes32 predecessor, bytes32 salt, uint256 delay) returns()", + want: true, }, { - name: "Sui proposal with unknown module", - timelockProposal: timelockProposalSuiUnknownModule, - signers: map[mcmstypes.ChainSelector][]common.Address{ - mcmstypes.ChainSelector(chainsel.SUI_TESTNET.Selector): { - common.HexToAddress("0xA5D5B0B844c8f11B61F28AC98BBA84dEA9b80953"), - }, - }, - assertion: func(t *testing.T, gotUpf string, err error) { - t.Helper() - require.NoError(t, err) - require.Equal(t, upfProposalSuiUnknownModule, gotUpf) - }, + name: "EVM bypasserExecuteBatch", + functionName: "function bypasserExecuteBatch((address,uint256,bytes)[] calls) payable returns()", + want: true, + }, + // Solana + { + name: "Solana ScheduleBatch", + functionName: "ScheduleBatch", + want: true, + }, + { + name: "Solana BypasserExecuteBatch", + functionName: "BypasserExecuteBatch", + want: true, + }, + // Sui + { + name: "Sui timelock_schedule_batch", + functionName: "mcms::timelock_schedule_batch", + want: true, + }, + { + name: "Sui timelock_bypasser_execute_batch", + functionName: "mcms::timelock_bypasser_execute_batch", + want: true, + }, + // Aptos + { + name: "Aptos timelock_schedule_batch", + functionName: "package::module::timelock_schedule_batch", + want: true, + }, + { + name: "Aptos timelock_bypasser_execute_batch", + functionName: "package::module::timelock_bypasser_execute_batch", + want: true, + }, + // TON + { + name: "TON ScheduleBatch", + functionName: "com.chainlink.ton.mcms.RBACTimelock::ScheduleBatch(0x12345678)", + want: true, + }, + { + name: "TON BypasserExecuteBatch", + functionName: "com.chainlink.ton.mcms.RBACTimelock::BypasserExecuteBatch(0xabcdef)", + want: true, + }, + // Non-matching + { + name: "unrelated function", + functionName: "function transfer(address to, uint256 amount) returns(bool)", + want: false, + }, + { + name: "empty string", + functionName: "", + want: false, + }, + { + name: "partial match without colon", + functionName: "timelock_schedule_batch", + want: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - - timelockProposal, err := mcms.NewTimelockProposal(strings.NewReader(tt.timelockProposal)) - require.NoError(t, err) - mcmProposal := convertTimelockProposal(t.Context(), t, timelockProposal) - - got, err := UpfConvertTimelockProposal(t.Context(), proposalCtx, env, timelockProposal, mcmProposal, tt.signers) - - tt.assertion(t, got, err) + got := isTimelockBatchFunction(tt.functionName) + require.Equal(t, tt.want, got) }) } } diff --git a/go.mod b/go.mod index 36c6a652..d5fb1822 100644 --- a/go.mod +++ b/go.mod @@ -37,6 +37,7 @@ require ( github.com/smartcontractkit/chainlink-protos/op-catalog v0.0.4 github.com/smartcontractkit/chainlink-testing-framework/framework v0.13.3 github.com/smartcontractkit/chainlink-testing-framework/seth v1.51.2 + github.com/smartcontractkit/chainlink-ton v0.0.0-20260120144738-c9d69aa78a47 github.com/smartcontractkit/chainlink-tron/relayer v0.0.11-0.20250815105909-75499abc4335 github.com/smartcontractkit/freeport v0.1.3-0.20250716200817-cb5dfd0e369e github.com/smartcontractkit/libocr v0.0.0-20250912173940-f3ab0246e23d @@ -76,7 +77,6 @@ require ( github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.10 // indirect github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20251124151448-0448aefdaab9 // indirect github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b // indirect - github.com/smartcontractkit/chainlink-ton v0.0.0-20260115170733-b16e9683d4d5 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index 9b0686cb..06c2e010 100644 --- a/go.sum +++ b/go.sum @@ -722,6 +722,8 @@ github.com/smartcontractkit/chainlink-testing-framework/seth v1.51.2 h1:ZJ/8Jx6B github.com/smartcontractkit/chainlink-testing-framework/seth v1.51.2/go.mod h1:kHYJnZUqiPF7/xN5273prV+srrLJkS77GbBXHLKQpx0= github.com/smartcontractkit/chainlink-ton v0.0.0-20260115170733-b16e9683d4d5 h1:qoXtC2Ypwt/4BYYCsjs58hnzL+38Mp5N7WYmN0cvMkM= github.com/smartcontractkit/chainlink-ton v0.0.0-20260115170733-b16e9683d4d5/go.mod h1:8Nbyr/8SUFNH9wmTlT4FNd80XzlO3RN5r2DQReeXg7k= +github.com/smartcontractkit/chainlink-ton v0.0.0-20260120144738-c9d69aa78a47 h1:rCO+HGhAYgnQQQfjNl0wAa3L/DQltIwRaxoaFTLu384= +github.com/smartcontractkit/chainlink-ton v0.0.0-20260120144738-c9d69aa78a47/go.mod h1:jeuUzo8fWXrqnMniJrtfmbbtE8FJr6why+Maj/Xz1ZU= github.com/smartcontractkit/chainlink-tron/relayer v0.0.11-0.20250815105909-75499abc4335 h1:7bxYNrPpygn8PUSBiEKn8riMd7CXMi/4bjTy0fHhcrY= github.com/smartcontractkit/chainlink-tron/relayer v0.0.11-0.20250815105909-75499abc4335/go.mod h1:ccjEgNeqOO+bjPddnL4lUrNLzyCvGCxgBjJdhFX3wa8= github.com/smartcontractkit/chainlink-tron/relayer/gotron-sdk v0.0.4 h1:J4qtAo0ZmgX5pIr8Y5mdC+J2rj2e/6CTUC263t6mGOM=