From cb6ea802d44766ac03043cc1308565b3880f6d7c Mon Sep 17 00:00:00 2001 From: Graham Goh Date: Tue, 4 Feb 2025 14:33:54 +1100 Subject: [PATCH] [DPA-1418]: feat(solana): simulate executor operation mcms (#277) Simulate the operation of executor ExecuteOperation. Per discussion [here](https://chainlink-core.slack.com/archives/C07NVBK16KS/p1738296756500949?thread_ts=1738215917.353599&cid=C07NVBK16KS), we want to simulate the actual wrapped operation instead of the mcms execute operation. - Added e2e - Added unit tests JIRA: https://smartcontract-it.atlassian.net/browse/DPA-1418 --- .changeset/cold-gifts-add.md | 5 ++ e2e/tests/solana/simulator.go | 37 ++++++++++ sdk/solana/encoder.go | 12 ++-- sdk/solana/encoder_test.go | 2 +- sdk/solana/executor.go | 11 +-- sdk/solana/simulator.go | 19 +++-- sdk/solana/simulator_test.go | 100 ++++++++++++++++++++++++++ sdk/solana/timelock_converter_test.go | 10 +-- 8 files changed, 169 insertions(+), 27 deletions(-) create mode 100644 .changeset/cold-gifts-add.md diff --git a/.changeset/cold-gifts-add.md b/.changeset/cold-gifts-add.md new file mode 100644 index 00000000..07c72a61 --- /dev/null +++ b/.changeset/cold-gifts-add.md @@ -0,0 +1,5 @@ +--- +"@smartcontractkit/mcms": minor +--- + +feat(solana): simulate executor operation mcms diff --git a/e2e/tests/solana/simulator.go b/e2e/tests/solana/simulator.go index 8ba132bd..4c10f653 100644 --- a/e2e/tests/solana/simulator.go +++ b/e2e/tests/solana/simulator.go @@ -11,6 +11,7 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/programs/system" "github.com/smartcontractkit/mcms" "github.com/smartcontractkit/mcms/sdk" @@ -109,3 +110,39 @@ func (s *SolanaTestSuite) TestSimulator_SimulateSetRoot() { } s.Require().NoError(err) } + +func (s *SolanaTestSuite) TestSimulator_SimulateOperation() { + ctx := context.Background() + + recipientAddress, err := solana.NewRandomPrivateKey() + s.Require().NoError(err) + + auth, err := solana.PrivateKeyFromBase58(privateKey) + s.Require().NoError(err) + + encoder := solanasdk.NewEncoder(s.ChainSelector, 1, false) + executor := solanasdk.NewExecutor(encoder, s.SolanaClient, auth) + simulator := solanasdk.NewSimulator(executor) + + ix, err := system.NewTransferInstruction( + 1*solana.LAMPORTS_PER_SOL, + auth.PublicKey(), + recipientAddress.PublicKey()).ValidateAndBuild() + s.Require().NoError(err) + + ixData, err := ix.Data() + s.Require().NoError(err) + + tx, err := solanasdk.NewTransaction(solana.SystemProgramID.String(), ixData, nil, ix.Accounts(), "System", []string{}) + s.Require().NoError(err) + + op := types.Operation{ + Transaction: tx, + ChainSelector: s.ChainSelector, + } + metadata := types.ChainMetadata{ + MCMAddress: s.MCMProgramID.String(), + } + err = simulator.SimulateOperation(ctx, metadata, op) + s.Require().NoError(err) +} diff --git a/sdk/solana/encoder.go b/sdk/solana/encoder.go index eab41540..f792d350 100644 --- a/sdk/solana/encoder.go +++ b/sdk/solana/encoder.go @@ -4,7 +4,6 @@ import ( "bytes" "encoding/binary" "encoding/json" - "errors" "fmt" "github.com/ethereum/go-ethereum/common" @@ -57,14 +56,11 @@ func (e *Encoder) HashOperation( return common.Hash{}, err } - toProgramID, _, err := ParseContractAddress(op.Transaction.To) - if errors.Is(err, ErrInvalidContractAddressFormat) { - var pkerr error - toProgramID, pkerr = solana.PublicKeyFromBase58(op.Transaction.To) - if pkerr != nil { - return common.Hash{}, fmt.Errorf("unable to get hash from base58 To address: %w", err) - } + toProgramID, err := ParseProgramID(op.Transaction.To) + if err != nil { + return common.Hash{}, fmt.Errorf("unable to prase program id from To field: %w", err) } + // Parse Additional fields to get the ix accounts var additionalFields AdditionalFields if op.Transaction.AdditionalFields != nil { diff --git a/sdk/solana/encoder_test.go b/sdk/solana/encoder_test.go index e98e9fe4..c6be9317 100644 --- a/sdk/solana/encoder_test.go +++ b/sdk/solana/encoder_test.go @@ -152,7 +152,7 @@ func TestEncoder_HashOperation(t *testing.T) { ChainSelector: testChainSelector, Transaction: types.Transaction{To: "invalid"}, }, - wantErr: "unable to get hash from base58 To address: invalid solana contract address format: \"invalid\"", + wantErr: "unable to prase program id from To field: unable to parse base58 solana program id: decode: invalid base58 digit ('l')", }, } for _, tt := range tests { diff --git a/sdk/solana/executor.go b/sdk/solana/executor.go index 164e0c7f..30424dce 100644 --- a/sdk/solana/executor.go +++ b/sdk/solana/executor.go @@ -3,7 +3,6 @@ package solana import ( "context" "encoding/json" - "errors" "fmt" "math" @@ -87,13 +86,9 @@ func (e *Executor) ExecuteOperation( if err = json.Unmarshal(op.Transaction.AdditionalFields, &additionalFields); err != nil { return types.TransactionResult{}, fmt.Errorf("unable to unmarshal additional fields: %w", err) } - toProgramID, _, err := ParseContractAddress(op.Transaction.To) - if errors.Is(err, ErrInvalidContractAddressFormat) { - var pkerr error - toProgramID, pkerr = solana.PublicKeyFromBase58(op.Transaction.To) - if pkerr != nil { - return types.TransactionResult{}, fmt.Errorf("unable to get hash from base58 To address: %w", err) - } + toProgramID, err := ParseProgramID(op.Transaction.To) + if err != nil { + return types.TransactionResult{}, fmt.Errorf("unable to prase program id from To field: %w", err) } ix := mcm.NewExecuteInstruction( diff --git a/sdk/solana/simulator.go b/sdk/solana/simulator.go index bae37e66..0e3edf59 100644 --- a/sdk/solana/simulator.go +++ b/sdk/solana/simulator.go @@ -2,6 +2,7 @@ package solana import ( "context" + "encoding/json" "fmt" "time" @@ -45,14 +46,22 @@ func (s *Simulator) SimulateSetRoot( func (s *Simulator) SimulateOperation( ctx context.Context, metadata types.ChainMetadata, operation types.Operation, ) error { - s.instructions = []solana.Instruction{} - nonce := uint32(0) - proof := []common.Hash{} - _, err := s.executor.ExecuteOperation(ctx, metadata, nonce, proof, operation) + var additionalFields AdditionalFields + if err := json.Unmarshal(operation.Transaction.AdditionalFields, &additionalFields); err != nil { + return fmt.Errorf("unable to unmarshal additional fields: %w", err) + } + + toProgramID, err := ParseProgramID(operation.Transaction.To) if err != nil { - return err + return fmt.Errorf("unable to prase program id from To field: %w", err) } + s.instructions = append(s.instructions, solana.NewInstruction( + toProgramID, + additionalFields.Accounts, + operation.Transaction.Data, + )) + return s.simulate(ctx) } diff --git a/sdk/solana/simulator_test.go b/sdk/solana/simulator_test.go index 8e019176..9b064fd8 100644 --- a/sdk/solana/simulator_test.go +++ b/sdk/solana/simulator_test.go @@ -3,10 +3,13 @@ package solana import ( "context" "errors" + "fmt" "testing" "github.com/ethereum/go-ethereum/common" "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/programs/system" + cselectors "github.com/smartcontractkit/chain-selectors" "github.com/stretchr/testify/require" "github.com/smartcontractkit/mcms/sdk/solana/mocks" @@ -78,3 +81,100 @@ func TestSimulator_SimulateSetRoot(t *testing.T) { }) } } + +func TestSimulator_SimulateOperation(t *testing.T) { + t.Parallel() + + auth, err := solana.NewRandomPrivateKey() + require.NoError(t, err) + + selector := cselectors.SOLANA_DEVNET.Selector + + testWallet, err := solana.NewRandomPrivateKey() + require.NoError(t, err) + + ix, err := system.NewTransferInstruction(20*solana.LAMPORTS_PER_SOL, auth.PublicKey(), testWallet.PublicKey()).ValidateAndBuild() + require.NoError(t, err) + + data, err := ix.Data() + require.NoError(t, err) + + tx, err := NewTransaction(solana.SystemProgramID.String(), data, nil, ix.Accounts(), "solana-testing", []string{}) + require.NoError(t, err) + + tests := []struct { + name string + givenOP types.Operation + setupMocks func(t *testing.T, client *mocks.JSONRPCClient) + expectedError string + }{ + { + name: "success: SimulateOperation", + givenOP: types.Operation{ + Transaction: tx, + ChainSelector: types.ChainSelector(selector), + }, + setupMocks: func(t *testing.T, m *mocks.JSONRPCClient) { + t.Helper() + mockSolanaSimulateTransaction(t, m, 50, nil, nil) + }, + }, + { + name: "error: invalid additional fields", + givenOP: types.Operation{ + Transaction: types.Transaction{ + AdditionalFields: []byte("invalid"), + }, + ChainSelector: types.ChainSelector(selector), + }, + setupMocks: func(t *testing.T, m *mocks.JSONRPCClient) { + t.Helper() + }, + expectedError: "unable to unmarshal additional fields: invalid character 'i' looking for beginning of value", + }, + { + name: "error: block hash fetch failed", + givenOP: types.Operation{ + Transaction: tx, + ChainSelector: types.ChainSelector(selector), + }, + setupMocks: func(t *testing.T, m *mocks.JSONRPCClient) { + t.Helper() + mockSolanaSimulateTransaction(t, m, 50, errors.New("block hash error"), nil) + }, + expectedError: "unable to simulate instruction: block hash error", + }, + { + name: "failure: SimulateTransaction error", + givenOP: types.Operation{ + Transaction: tx, + ChainSelector: types.ChainSelector(selector), + }, + setupMocks: func(t *testing.T, client *mocks.JSONRPCClient) { + t.Helper() + mockSolanaSimulateTransaction(t, client, 50, nil, errors.New("SimulateTransaction error")) + }, + expectedError: "SimulateTransaction error", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + executor, client := newTestExecutor(t, auth, testChainSelector) + simulator := NewSimulator(executor) + + tt.setupMocks(t, client) + + err = simulator.SimulateOperation(context.Background(), types.ChainMetadata{ + MCMAddress: fmt.Sprintf("%s.%s", testMCMProgramID.String(), testPDASeed), + }, tt.givenOP) + + if tt.expectedError != "" { + require.ErrorContains(t, err, tt.expectedError) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/sdk/solana/timelock_converter_test.go b/sdk/solana/timelock_converter_test.go index 2d85ba1a..ddaa266a 100644 --- a/sdk/solana/timelock_converter_test.go +++ b/sdk/solana/timelock_converter_test.go @@ -116,7 +116,7 @@ func Test_TimelockConverter_ConvertBatchToChainOperations(t *testing.T) { AdditionalFields: toJSON(t, AdditionalFields{Accounts: []*solana.AccountMeta{ {PublicKey: solana.MPK("3x12f1G4bt9j7rsBfLE7rZQ5hXoHuHdjtUr2UKW8gjQp"), IsWritable: true}, {PublicKey: solana.MPK("GYWcPzXkdzY9DJLcbFs67phqyYzmJxeEKSTtqEoo8oKz")}, - {PublicKey: solana.MPK(proposerAC.PublicKey().String())}, + {PublicKey: proposerAC.PublicKey()}, {PublicKey: solana.MPK("62gDM6BRLf2w1yXfmpePUTsuvbeBbu4QqdjV32wcc4UG"), IsWritable: true}, {PublicKey: solana.MPK("11111111111111111111111111111111")}, }}), @@ -135,7 +135,7 @@ func Test_TimelockConverter_ConvertBatchToChainOperations(t *testing.T) { AdditionalFields: toJSON(t, AdditionalFields{Accounts: []*solana.AccountMeta{ {PublicKey: solana.MPK("3x12f1G4bt9j7rsBfLE7rZQ5hXoHuHdjtUr2UKW8gjQp"), IsWritable: true}, {PublicKey: solana.MPK("GYWcPzXkdzY9DJLcbFs67phqyYzmJxeEKSTtqEoo8oKz")}, - {PublicKey: solana.MPK(proposerAC.PublicKey().String())}, + {PublicKey: proposerAC.PublicKey()}, {PublicKey: solana.MPK("62gDM6BRLf2w1yXfmpePUTsuvbeBbu4QqdjV32wcc4UG"), IsWritable: true}, {PublicKey: solana.MPK("11111111111111111111111111111111")}, }}), @@ -154,7 +154,7 @@ func Test_TimelockConverter_ConvertBatchToChainOperations(t *testing.T) { AdditionalFields: toJSON(t, AdditionalFields{Accounts: []*solana.AccountMeta{ {PublicKey: solana.MPK("3x12f1G4bt9j7rsBfLE7rZQ5hXoHuHdjtUr2UKW8gjQp"), IsWritable: true}, {PublicKey: solana.MPK("GYWcPzXkdzY9DJLcbFs67phqyYzmJxeEKSTtqEoo8oKz")}, - {PublicKey: solana.MPK(proposerAC.PublicKey().String())}, + {PublicKey: proposerAC.PublicKey()}, {PublicKey: solana.MPK("62gDM6BRLf2w1yXfmpePUTsuvbeBbu4QqdjV32wcc4UG"), IsWritable: true}, {PublicKey: solana.MPK("11111111111111111111111111111111")}, }}), @@ -173,7 +173,7 @@ func Test_TimelockConverter_ConvertBatchToChainOperations(t *testing.T) { AdditionalFields: toJSON(t, AdditionalFields{Accounts: []*solana.AccountMeta{ {PublicKey: solana.MPK("3x12f1G4bt9j7rsBfLE7rZQ5hXoHuHdjtUr2UKW8gjQp"), IsWritable: true}, {PublicKey: solana.MPK("GYWcPzXkdzY9DJLcbFs67phqyYzmJxeEKSTtqEoo8oKz")}, - {PublicKey: solana.MPK(proposerAC.PublicKey().String())}, + {PublicKey: proposerAC.PublicKey()}, {PublicKey: solana.MPK("62gDM6BRLf2w1yXfmpePUTsuvbeBbu4QqdjV32wcc4UG"), IsWritable: true}, }}), OperationMetadata: types.OperationMetadata{ @@ -191,7 +191,7 @@ func Test_TimelockConverter_ConvertBatchToChainOperations(t *testing.T) { AdditionalFields: toJSON(t, AdditionalFields{Accounts: []*solana.AccountMeta{ {PublicKey: solana.MPK("3x12f1G4bt9j7rsBfLE7rZQ5hXoHuHdjtUr2UKW8gjQp"), IsWritable: true}, {PublicKey: solana.MPK("GYWcPzXkdzY9DJLcbFs67phqyYzmJxeEKSTtqEoo8oKz")}, - {PublicKey: solana.MPK(proposerAC.PublicKey().String())}, + {PublicKey: proposerAC.PublicKey()}, {PublicKey: solana.MPK("62gDM6BRLf2w1yXfmpePUTsuvbeBbu4QqdjV32wcc4UG"), IsWritable: true}, }}), OperationMetadata: types.OperationMetadata{