Skip to content
Merged
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
81 changes: 70 additions & 11 deletions cmd/workflow/simulate/chain/solana/chaintype.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ package solana

import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"

Expand Down Expand Up @@ -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=<your-64-byte-base58-keypair>\n\n" +
"You can generate a test key using: solana-keygen new\n" +
"Then fund it on devnet: solana airdrop 10 <your-address> --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
Expand Down
152 changes: 152 additions & 0 deletions cmd/workflow/simulate/chain/solana/chaintype_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ package solana

import (
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
"time"

Expand All @@ -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 {
Expand Down Expand Up @@ -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())
})
}
}
Loading