diff --git a/cmd/workflow/simulate/chain/solana/chaintype.go b/cmd/workflow/simulate/chain/solana/chaintype.go index 24a3e9fd..1feae987 100644 --- a/cmd/workflow/simulate/chain/solana/chaintype.go +++ b/cmd/workflow/simulate/chain/solana/chaintype.go @@ -2,7 +2,10 @@ package solana import ( "context" + "errors" "fmt" + "os" + "path/filepath" "strconv" "strings" @@ -99,25 +102,81 @@ 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" + - "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=4wBqpZM9xaSheZzJSMawUHDgZ7miWfSsx...meRUJ1s", ) } - // 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 } // ResolveTriggerData fetches the Solana log payload for the given selector by diff --git a/cmd/workflow/simulate/chain/solana/chaintype_test.go b/cmd/workflow/simulate/chain/solana/chaintype_test.go index f46c43f7..c9a275f4 100644 --- a/cmd/workflow/simulate/chain/solana/chaintype_test.go +++ b/cmd/workflow/simulate/chain/solana/chaintype_test.go @@ -2,6 +2,9 @@ package solana import ( "context" + "encoding/json" + "os" + "path/filepath" "testing" "time" @@ -18,6 +21,7 @@ import ( solanafakes "github.com/smartcontractkit/chainlink-solana/contracts/capabilities/fakes" "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain" + "github.com/smartcontractkit/cre-cli/internal/settings" ) func newSolanaChainType() *SolanaChainType { @@ -233,3 +237,151 @@ func TestSolanaChainType_CollectCLIInputs_DefaultsOnly(t *testing.T) { result := ct.CollectCLIInputs(v) assert.Empty(t, result) } + +// 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()) +} + +// 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 +} + +// 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 +} + +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()) + }) + } +}