From 2ce637e8d2c68903c7df9abaae6e8dedc6ef191b Mon Sep 17 00:00:00 2001 From: yashnevatia Date: Tue, 28 Jul 2026 13:45:23 +0100 Subject: [PATCH 1/2] Better key resolution for solana --- .../simulate/chain/solana/chaintype.go | 78 ++++++-- .../simulate/chain/solana/chaintype_test.go | 169 ++++++++++++++++++ 2 files changed, 237 insertions(+), 10 deletions(-) create mode 100644 cmd/workflow/simulate/chain/solana/chaintype_test.go diff --git a/cmd/workflow/simulate/chain/solana/chaintype.go b/cmd/workflow/simulate/chain/solana/chaintype.go index 45fc1135..5e384591 100644 --- a/cmd/workflow/simulate/chain/solana/chaintype.go +++ b/cmd/workflow/simulate/chain/solana/chaintype.go @@ -3,6 +3,8 @@ package solana import ( "context" "fmt" + "os" + "path/filepath" "strings" "github.com/gagliardetto/solana-go" @@ -90,23 +92,79 @@ func (ct *SolanaChainType) ResolveKey(s *settings.Settings, broadcast bool) (int if raw == "" { return nil, fmt.Errorf( "CRE_SOLANA_PRIVATE_KEY is required for Solana workflow simulation.\n\n" + - "The Solana test network requires the transmitter account (derived from your private key) to exist and be funded on-chain.\n" + - "Please set your private key in your .env file or system environment:\n\n" + - " CRE_SOLANA_PRIVATE_KEY=\n\n" + - "You can generate a test key using: solana-keygen new\n" + - "Then fund it on devnet: solana airdrop 10 --url devnet", + "The Solana test network requires the transmitter account (derived from your private key) to exist and be funded on-chain.\n\n" + + "If you already have a Solana CLI keypair, point the variable at the file:\n\n" + + " CRE_SOLANA_PRIVATE_KEY=~/.config/solana/id.json\n\n" + + "To create one:\n\n" + + " solana-keygen new\n" + + " solana airdrop 2 --url devnet\n\n" + + "Fund the account on the same cluster your RPC points at; an account funded on\n" + + "mainnet is invisible to a devnet simulation (it fails with AccountNotFound).\n" + + "Check with: solana balance --url devnet\n\n" + + "and then point the variable at the file:\n\n" + + " CRE_SOLANA_PRIVATE_KEY=~/.config/solana/id.json\n\n" + + "A base58-encoded 64-byte keypair is also accepted:\n\n" + + " CRE_SOLANA_PRIVATE_KEY=4wBqpZM9xaSheZzJSMawUHDgZ7miWfSsxmeRUJ1s...", ) } - // Try base58 (64-byte solana keypair, standard Solana CLI / wallet format). - if key, err := solana.PrivateKeyFromBase58(raw); err == nil && len(key) == 64 { - if broadcast && key.PublicKey().IsZero() { - return nil, fmt.Errorf("CRE_SOLANA_PRIVATE_KEY decodes to a zero key; refusing to broadcast") + key, err := parseSolanaKey(raw) + if err != nil { + return nil, err + } + if broadcast && key.PublicKey().IsZero() { + return nil, fmt.Errorf("CRE_SOLANA_PRIVATE_KEY decodes to a zero key; refusing to broadcast") + } + return key, nil +} + +// parseSolanaKey accepts the three shapes a user is likely to have on hand: +// the contents of a `solana-keygen` keyfile pasted inline, a base58-encoded +// 64-byte keypair, or a path to a keyfile. `solana-keygen new` writes the JSON +// byte-array form and has no flag to print the base58 secret, so requiring +// base58 alone leaves users with no way to use a freshly generated key. +// Base58 is tried before the path interpretation so existing configs resolve +// exactly as they did before, and no path-shape guessing is needed. +func parseSolanaKey(raw string) (solana.PrivateKey, error) { + if strings.HasPrefix(raw, "[") { + key, err := solana.PrivateKeyFromSolanaKeygenFileBytes([]byte(raw)) + if err != nil { + return nil, keyFormatError(err) } return key, nil } - return nil, fmt.Errorf("CRE_SOLANA_PRIVATE_KEY must be a 64-byte base58 keypair") + if key, err := solana.PrivateKeyFromBase58(raw); err == nil { + return key, nil + } + + path, err := expandHome(raw) + if err != nil { + return nil, keyFormatError(err) + } + key, err := solana.PrivateKeyFromSolanaKeygenFile(path) + if err != nil { + return nil, keyFormatError(err) + } + return key, nil +} + +func keyFormatError(err error) error { + return fmt.Errorf( + "CRE_SOLANA_PRIVATE_KEY must be a base58-encoded 64-byte keypair, a path to a "+ + "solana-keygen keyfile (e.g. ~/.config/solana/id.json), or that file's JSON contents: %w", err) +} + +// expandHome expands a leading ~ to the user's home directory. +func expandHome(path string) (string, error) { + if !strings.HasPrefix(path, "~") { + return path, nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, path[1:]), nil } func (ct *SolanaChainType) ResolveTriggerData(_ context.Context, _ uint64, _ chain.TriggerParams) (interface{}, error) { diff --git a/cmd/workflow/simulate/chain/solana/chaintype_test.go b/cmd/workflow/simulate/chain/solana/chaintype_test.go new file mode 100644 index 00000000..c1cd9011 --- /dev/null +++ b/cmd/workflow/simulate/chain/solana/chaintype_test.go @@ -0,0 +1,169 @@ +package solana + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/gagliardetto/solana-go" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/cre-cli/internal/settings" +) + +func newSolanaChainType() *SolanaChainType { + lg := zerolog.Nop() + return &SolanaChainType{log: &lg} +} + +// keygenJSON renders key as the JSON array of numbers that `solana-keygen` +// writes. json.Marshal on a []byte would emit a base64 string instead, which +// is not the format users actually have on disk. +func keygenJSON(t *testing.T, key []byte) []byte { + t.Helper() + nums := make([]uint16, len(key)) + for i, b := range key { + nums[i] = uint16(b) + } + b, err := json.Marshal(nums) + require.NoError(t, err) + return b +} + +// writeKeygenFile writes key in the JSON byte-array form that `solana-keygen` +// produces and returns the file path. +func writeKeygenFile(t *testing.T, dir, name string, key []byte) string { + t.Helper() + path := filepath.Join(dir, name) + require.NoError(t, os.WriteFile(path, keygenJSON(t, key), 0o600)) + return path +} + +func TestSolanaChainType_ResolveKey(t *testing.T) { + key, err := solana.NewRandomPrivateKey() + require.NoError(t, err) + + dir := t.TempDir() + keyfile := writeKeygenFile(t, dir, "id.json", key) + shortFile := writeKeygenFile(t, dir, "short.json", key[:32]) + + inline := keygenJSON(t, key) + + tests := []struct { + name string + pk string + broadcast bool + wantErr bool + errContains string + }{ + { + name: "base58 keypair, non-broadcast", + pk: key.String(), + }, + { + name: "base58 keypair, broadcast", + pk: key.String(), + broadcast: true, + }, + { + name: "path to solana-keygen keyfile", + pk: keyfile, + }, + { + name: "path to solana-keygen keyfile, broadcast", + pk: keyfile, + broadcast: true, + }, + { + name: "inline keyfile contents", + pk: string(inline), + }, + { + name: "empty", + pk: "", + wantErr: true, + errContains: "CRE_SOLANA_PRIVATE_KEY is required", + }, + { + name: "empty suggests the keyfile path", + pk: " ", + wantErr: true, + errContains: "~/.config/solana/id.json", + }, + { + name: "garbage", + pk: "not-a-key", + wantErr: true, + errContains: "must be a base58-encoded 64-byte keypair", + }, + { + name: "nonexistent path", + pk: filepath.Join(dir, "missing.json"), + wantErr: true, + errContains: "must be a base58-encoded 64-byte keypair", + }, + { + name: "keyfile with 32 bytes instead of 64", + pk: shortFile, + wantErr: true, + errContains: "invalid private key length 32", + }, + { + name: "inline contents with 32 bytes instead of 64", + pk: "[1,2,3]", + wantErr: true, + errContains: "invalid private key length 3", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ct := newSolanaChainType() + s := &settings.Settings{User: settings.UserSettings{ + PrivateKeys: map[string]string{settings.Solana.Name: tt.pk}, + }} + + got, err := ct.ResolveKey(s, tt.broadcast) + if tt.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errContains) + assert.Nil(t, got) + return + } + require.NoError(t, err) + pk, ok := got.(solana.PrivateKey) + require.True(t, ok, "expected solana.PrivateKey, got %T", got) + assert.Equal(t, key.PublicKey(), pk.PublicKey()) + }) + } +} + +// The ~ form is the one we tell users to configure, so cover it explicitly. +func TestSolanaChainType_ResolveKey_TildePath(t *testing.T) { + home, err := os.UserHomeDir() + require.NoError(t, err) + + key, err := solana.NewRandomPrivateKey() + require.NoError(t, err) + + dir, err := os.MkdirTemp(home, "cre-solana-key-test-") + require.NoError(t, err) + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + + keyfile := writeKeygenFile(t, dir, "id.json", key) + rel, err := filepath.Rel(home, keyfile) + require.NoError(t, err) + + s := &settings.Settings{User: settings.UserSettings{ + PrivateKeys: map[string]string{settings.Solana.Name: filepath.Join("~", rel)}, + }} + + got, err := newSolanaChainType().ResolveKey(s, false) + require.NoError(t, err) + pk, ok := got.(solana.PrivateKey) + require.True(t, ok, "expected solana.PrivateKey, got %T", got) + assert.Equal(t, key.PublicKey(), pk.PublicKey()) +} From b92e8866b0fcd5cd44edbb0403438dc8799bdd23 Mon Sep 17 00:00:00 2001 From: yashnevatia Date: Tue, 28 Jul 2026 20:39:20 +0100 Subject: [PATCH 2/2] lint --- cmd/workflow/simulate/chain/solana/chaintype.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cmd/workflow/simulate/chain/solana/chaintype.go b/cmd/workflow/simulate/chain/solana/chaintype.go index ded9fec3..1feae987 100644 --- a/cmd/workflow/simulate/chain/solana/chaintype.go +++ b/cmd/workflow/simulate/chain/solana/chaintype.go @@ -2,6 +2,7 @@ package solana import ( "context" + "errors" "fmt" "os" "path/filepath" @@ -101,7 +102,7 @@ func (ct *SolanaChainType) ResolveKey(s *settings.Settings, broadcast bool) (int // will fail when the RPC tries to access a non-existent signer account. // Solution: Mandate CRE_SOLANA_PRIVATE_KEY for all Solana workflow simulations. if raw == "" { - return nil, fmt.Errorf( + return nil, errors.New( "CRE_SOLANA_PRIVATE_KEY is required for Solana workflow simulation.\n\n" + "The Solana test network requires the transmitter account (derived from your private key) to exist and be funded on-chain.\n\n" + "If you already have a Solana CLI keypair, point the variable at the file:\n\n" + @@ -115,7 +116,7 @@ func (ct *SolanaChainType) ResolveKey(s *settings.Settings, broadcast bool) (int "and then point the variable at the file:\n\n" + " CRE_SOLANA_PRIVATE_KEY=~/.config/solana/id.json\n\n" + "A base58-encoded 64-byte keypair is also accepted:\n\n" + - " CRE_SOLANA_PRIVATE_KEY=4wBqpZM9xaSheZzJSMawUHDgZ7miWfSsxmeRUJ1s...", + " CRE_SOLANA_PRIVATE_KEY=4wBqpZM9xaSheZzJSMawUHDgZ7miWfSsx...meRUJ1s", ) }