Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Simulate execute on solana SDK [DPA-1418] #250

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/wet-bugs-mix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@smartcontractkit/mcms": minor
---

Adds execute operation simulation to solana sdk
57 changes: 57 additions & 0 deletions e2e/tests/solana/simulation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
//go:build e2e
// +build e2e

package solanae2e

import (
"context"
"time"

"github.com/gagliardetto/solana-go"
"github.com/gagliardetto/solana-go/programs/system"

solana2 "github.com/smartcontractkit/mcms/e2e/utils/solana"
solanasdk "github.com/smartcontractkit/mcms/sdk/solana"
"github.com/smartcontractkit/mcms/types"
)

// Test_Simulator_SimulateOperation tests the operation simulation functionality.
func (s *SolanaTestSuite) Test_Simulator_SimulateOperation() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
s.T().Cleanup(cancel)

recipientAddress, err := solana.NewRandomPrivateKey()
s.Require().NoError(err)

auth, err := solana.PrivateKeyFromBase58(privateKey)
s.Require().NoError(err)
solana2.FundAccounts(s.T(), ctx, []solana.PublicKey{auth.PublicKey()}, 20*solana.LAMPORTS_PER_SOL, s.SolanaClient)

// Create a new simulator
simulator, err := solanasdk.NewSimulator(
s.SolanaClient,
auth,
solanasdk.NewEncoder(s.ChainSelector, 1, false),
)
s.Require().NoError(err)

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, 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)
}
4 changes: 2 additions & 2 deletions e2e/utils/solana/testutils.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ func FundAccounts(

var sigs = make([]solana.Signature, 0, len(accounts))
for _, v := range accounts {
sig, err := solanaGoClient.RequestAirdrop(ctx, v, solAmount*solana.LAMPORTS_PER_SOL, rpc.CommitmentFinalized)
sig, err := solanaGoClient.RequestAirdrop(ctx, v, solAmount*solana.LAMPORTS_PER_SOL, rpc.CommitmentConfirmed)
require.NoError(t, err)
sigs = append(sigs, sig)
}
Expand All @@ -47,7 +47,7 @@ func FundAccounts(

unconfirmedTxCount := 0
for _, res := range statusRes.Value {
if res == nil || res.ConfirmationStatus == rpc.ConfirmationStatusProcessed || res.ConfirmationStatus == rpc.ConfirmationStatusConfirmed {
if res == nil || res.ConfirmationStatus == rpc.ConfirmationStatusProcessed {
unconfirmedTxCount++
}
}
Expand Down
113 changes: 113 additions & 0 deletions sdk/solana/simulator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package solana

import (
"context"
"encoding/json"
"errors"
"fmt"

"github.com/gagliardetto/solana-go"
"github.com/gagliardetto/solana-go/rpc"

"github.com/smartcontractkit/mcms/types"
)

type Simulator struct {
client *rpc.Client
encoder *Encoder
inspector *Inspector
auth solana.PrivateKey
}

// NewSimulator creates a new Solana Simulator
func NewSimulator(client *rpc.Client, auth solana.PrivateKey, encoder *Encoder) (*Simulator, error) {
if client == nil {
return nil, errors.New("Simulator was created without a Solana RPC client")
}

if encoder == nil {
return nil, errors.New("Simulator was created without an encoder")
}

return &Simulator{
client: client,
encoder: encoder,
auth: auth,
inspector: NewInspector(client),
}, nil
}

func (s *Simulator) SimulateSetRoot(
ctx context.Context,
originCaller solana.PublicKey,
metadata types.ChainMetadata,
proof [][]byte,
root [32]byte,
validUntil uint32,
sortedSignatures []types.Signature,
) error {
return fmt.Errorf("not implemented")
}

func (s *Simulator) SimulateOperation(
Copy link
Contributor

@graham-chainlink graham-chainlink Jan 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this was kind of what i was trying to avoid in SetRoot - another implementation for simulation, thats why i was experimenting the simulatable framework so we can easily make the existing implementation simulatable.

With the framework, it would be only a few lines of change for you here i believe? And the benefit is we get to test the most of the actual implementation of ExecuteOperation. (same code)

ctx context.Context,
// We don't need the metadata for simulating on solana, since the RPC client already know what chain we are on.
_ types.ChainMetadata,
op types.Operation,
) error {
// Parse the inner instruction from the operation
var additionalFields AdditionalFields
if err := json.Unmarshal(op.Transaction.AdditionalFields, &additionalFields); err != nil {
return 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 fmt.Errorf("unable to parse the 'To' address: %w", err)
}
}

// Build the instruction
innerInstruction := solana.NewInstruction(
toProgramID,
additionalFields.Accounts,
op.Transaction.Data,
)
recentBlockHash, err := s.client.GetRecentBlockhash(ctx, rpc.CommitmentConfirmed)
if err != nil {
return fmt.Errorf("unable to get recent blockhash: %w", err)
}

// Build the transaction with the inner instruction
tx, err := solana.NewTransaction(
[]solana.Instruction{innerInstruction},
recentBlockHash.Value.Blockhash,
solana.TransactionPayer(s.auth.PublicKey()),
)
if err != nil {
return fmt.Errorf("unable to create transaction: %w", err)
}
_, err = tx.Sign(
func(key solana.PublicKey) *solana.PrivateKey {
if s.auth.PublicKey().Equals(key) {
return &s.auth
}

return nil
},
)
if err != nil {
return fmt.Errorf("unable to sign transaction: %w", err)
}

// Simulate the transaction
_, err = s.client.SimulateTransaction(ctx, tx)
if err != nil {
return fmt.Errorf("unable to simulate transaction: %w", err)
}

return nil
}
148 changes: 148 additions & 0 deletions sdk/solana/simulator_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package solana

import (
"context"
"errors"
"fmt"
"testing"

"github.com/gagliardetto/solana-go"
"github.com/gagliardetto/solana-go/programs/system"
"github.com/gagliardetto/solana-go/rpc"
cselectors "github.com/smartcontractkit/chain-selectors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/smartcontractkit/mcms/sdk/solana/mocks"
"github.com/smartcontractkit/mcms/types"
)

func TestNewSimulator(t *testing.T) {
t.Parallel()

client := &rpc.Client{}
auth := solana.MustPrivateKeyFromBase58("DmPfeHBC8Brf8s5qQXi25bmJ996v6BHRtaLc6AH51yFGSqQpUMy1oHkbbXobPNBdgGH2F29PAmoq9ZZua4K9vCc")
chainSelector := types.ChainSelector(cselectors.SOLANA_DEVNET.Selector)
encoder := NewEncoder(chainSelector, 1, false)

simulator, err := NewSimulator(client, auth, encoder)
require.NoError(t, err)
require.NotNil(t, simulator)
// test with nil client
simulator, err = NewSimulator(nil, auth, encoder)
require.EqualError(t, err, "Simulator was created without a Solana RPC client")
require.Nil(t, simulator)

// test with nil encoder
simulator, err = NewSimulator(client, auth, nil)
require.EqualError(t, err, "Simulator was created without an encoder")
require.Nil(t, simulator)
}

func TestSimulator_SimulateOperation(t *testing.T) {
t.Parallel()

type args struct {
metadata types.ChainMetadata
op types.Operation
}
selector := cselectors.SOLANA_DEVNET.Selector
auth, err := solana.PrivateKeyFromBase58(dummyPrivateKey)
require.NoError(t, err)
contractID := fmt.Sprintf("%s.%s", testMCMProgramID.String(), testPDASeed)

ctx := context.Background()

require.NoError(t, err)
testWallet := solana.NewWallet()
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, ix.Accounts(), "solana-testing", []string{})
require.NoError(t, err)
tests := []struct {
name string
args args

mockSetup func(*mocks.JSONRPCClient)
assertion assert.ErrorAssertionFunc
wantErr error
}{
{
name: "success: SimulateOperation",
args: args{
metadata: types.ChainMetadata{
MCMAddress: contractID,
},
op: types.Operation{
Transaction: tx,
ChainSelector: types.ChainSelector(selector),
},
},
mockSetup: func(m *mocks.JSONRPCClient) {
mockSolanaSimulation(t, m, 20, 5, "2QUBE2GqS8PxnGP1EBrWpLw3La4XkEUz5NKXJTdTHoA43ANkf5fqKwZ8YPJVAi3ApefbbbCYJipMVzUa7kg3a7v6", nil)
},

assertion: assert.NoError,
},
{
name: "error: invalid additional fields",
args: args{
metadata: types.ChainMetadata{
MCMAddress: contractID,
},

op: types.Operation{
Transaction: types.Transaction{
AdditionalFields: []byte("invalid"),
},
ChainSelector: types.ChainSelector(selector),
},
},
mockSetup: func(m *mocks.JSONRPCClient) {},
wantErr: fmt.Errorf("unable to unmarshal additional fields: invalid character 'i' looking for beginning of value"),
assertion: func(t assert.TestingT, err error, i ...any) bool {
return assert.EqualError(t, err, "unable to unmarshal additional fields: invalid character 'i' looking for beginning of value")
},
},
{
name: "error: block hash fetch failed",
args: args{
metadata: types.ChainMetadata{
MCMAddress: contractID,
},

op: types.Operation{
Transaction: tx,
ChainSelector: types.ChainSelector(selector),
},
},
mockSetup: func(m *mocks.JSONRPCClient) {
mockSolanaSimulation(t, m, 20, 5, "2QUBE2GqS8PxnGP1EBrWpLw3La4XkEUz5NKXJTdTHoA43ANkf5fqKwZ8YPJVAi3ApefbbbCYJipMVzUa7kg3a7v6", errors.New("failed to simulate"))
},
wantErr: errors.New("failed to simulate"),
assertion: func(t assert.TestingT, err error, i ...any) bool {
return assert.EqualError(t, err, "unable to get recent blockhash: failed to simulate")
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

jsonRPCClient := mocks.NewJSONRPCClient(t)
encoder := NewEncoder(types.ChainSelector(selector), 0, false)
client := rpc.NewWithCustomRPCClient(jsonRPCClient)
tt.mockSetup(jsonRPCClient)
sim, err := NewSimulator(client, auth, encoder)
require.NoError(t, err)
err = sim.SimulateOperation(ctx, tt.args.metadata, tt.args.op)
if tt.wantErr != nil {
tt.assertion(t, err, tt.wantErr)
} else {
require.NoError(t, err)
}
})
}
}
42 changes: 42 additions & 0 deletions sdk/solana/utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,48 @@ func mockSolanaTransaction(
}).Once()
}

func mockSolanaSimulation(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

haha damn, looks like we overlap again lol

t *testing.T, client *mocks.JSONRPCClient, lastBlockHeight uint64, slot uint64, signature string, mockError error,
) {
t.Helper()

client.EXPECT().CallForInto(
anyContext, mock.Anything, "getRecentBlockhash", []any{rpc.M{"commitment": rpc.CommitmentConfirmed}},
).RunAndReturn(func(_ context.Context, output any, _ string, _ []any) error {
result, ok := output.(**rpc.GetRecentBlockhashResult)
require.True(t, ok)

*result = &rpc.GetRecentBlockhashResult{Value: &rpc.BlockhashResult{
Blockhash: solana.MustHashFromBase58(randomPublicKey(t).String()),
FeeCalculator: rpc.FeeCalculator{
LamportsPerSignature: 1,
},
}}

return mockError
}).Once()
if mockError != nil {
return
}
client.EXPECT().CallForInto(
anyContext, mock.Anything, "simulateTransaction", sendTransactionParams(t),
).RunAndReturn(func(_ context.Context, output any, _ string, _ []any) error {
result, ok := output.(**rpc.SimulateTransactionResponse)
require.True(t, ok)

*result = &rpc.SimulateTransactionResponse{
Value: &rpc.SimulateTransactionResult{
Err: nil,
Logs: nil,
Accounts: nil,
UnitsConsumed: ptrTo(uint64(1)),
},
}

return mockError
}).Once()
}

var sendTransactionParams = func(t *testing.T) any {
t.Helper()

Expand Down
Loading