From 2c2ca6a27899295dc87e48e4378671dbbe06ac48 Mon Sep 17 00:00:00 2001 From: Yong Kang Chia <165454358+yongkangchia@users.noreply.github.com> Date: Tue, 2 Jul 2024 11:17:20 +0800 Subject: [PATCH] [BCI-2953] Initial Aptos keystore (#13564) * chain type to include aptos * aptos key ed25519 implementation * add aptos to keystore master * added exports for keys * removed sdk dependency * updated go mod * Added tests for account sdk deps * added aptos keystore * added interface assertion to solana * added aptos keyring * added aptos keystore mocks * added aptos key controller & router * Added aptos keys command * added aptos keystore e2e tests * Added Aptos network ORM test for key * Added aptos ocr2 keys support * Revert "Added Aptos network ORM test for key" This reverts commit 444fe9509647db003b501abd2d8cb72941eea141. * Added Aptos export functionality * Modified Aptos Accounts for key rotation * added aptos keystore to orm validation * Addded Change logs * Updated keys implementation to remove address * Revert "updated go mod" This reverts commit a8f7e84c8b868eda256f71b576251ce1e76247b6. * Update changeset * Updated GraphQL to include Aptos * Linting fixes * added aptos configs for keystore * Changed Aptos config to RawConfig * Added Mock file for general config * using *bool type for aptos enabled * added debug for error * Fixed tests + added ORM * Added Test Scripts * Updated type of raw config * update all packages go mod tidy * update all packages go mod * Update core/services/chainlink/config.go Co-authored-by: Jordan Krage * Removed Address in key * Fixed merge conflicts * nit changes based on reviews * Added typecast check for keyring * Added config type cast check * make generate update version * update validation method * update config validation * Removed Pointer type for RawConfig * Updated Validate with better error message * Added Error config * Update struct of RawConfig * removed uncessary config validation * Added missing starknet keystore tests * added styling feedback * Update core/services/keystore/keys/aptoskey/account.go Co-authored-by: Jordan Krage * Removed empty array table for config * fixed lint --------- Co-authored-by: Jordan Krage --- .changeset/real-fireants-rule.md | 5 + core/cmd/app.go | 1 + core/cmd/aptos_keys_commands.go | 57 ++++ core/cmd/aptos_keys_commands_test.go | 175 ++++++++++++ core/config/app_config.go | 1 + core/internal/cltest/cltest.go | 2 + core/services/chainlink/config.go | 26 ++ core/services/chainlink/config_general.go | 9 + core/services/chainlink/config_test.go | 5 +- .../chainlink/mocks/general_config.go | 18 ++ .../chainlink/testdata/config-invalid.toml | 3 + core/services/job/job_orm_test.go | 12 + core/services/job/orm.go | 6 +- core/services/keystore/aptos.go | 182 ++++++++++++ core/services/keystore/aptos_test.go | 138 +++++++++ core/services/keystore/chaintype/chaintype.go | 4 +- .../keystore/keys/aptoskey/account.go | 72 +++++ .../keystore/keys/aptoskey/account_test.go | 141 +++++++++ .../services/keystore/keys/aptoskey/export.go | 48 ++++ .../keystore/keys/aptoskey/export_test.go | 19 ++ core/services/keystore/keys/aptoskey/key.go | 103 +++++++ .../keystore/keys/ocr2key/aptos_keyring.go | 118 ++++++++ .../keys/ocr2key/aptos_keyring_test.go | 58 ++++ .../keystore/keys/ocr2key/cosmos_keyring.go | 13 +- core/services/keystore/keys/ocr2key/export.go | 2 + .../keystore/keys/ocr2key/export_test.go | 1 + .../keystore/keys/ocr2key/key_bundle.go | 7 + core/services/keystore/keys/solkey/key.go | 2 +- core/services/keystore/keystoretest.go | 1 + core/services/keystore/master.go | 10 + core/services/keystore/mocks/aptos.go | 268 ++++++++++++++++++ core/services/keystore/mocks/master.go | 20 ++ core/services/keystore/models.go | 18 ++ core/services/keystore/solana.go | 3 + core/web/aptos_keys_controller.go | 12 + core/web/aptos_keys_controller_test.go | 109 +++++++ core/web/auth/auth_test.go | 10 + core/web/presenters/aptos_key.go | 32 +++ core/web/resolver/ocr2_keys.go | 6 + core/web/resolver/ocr2_keys_test.go | 1 + core/web/router.go | 1 + core/web/schema/type/ocr2_keys.graphql | 1 + testdata/scripts/help-all/help-all.txtar | 6 + testdata/scripts/keys/aptos/help.txtar | 20 ++ testdata/scripts/keys/help.txtar | 1 + 45 files changed, 1737 insertions(+), 10 deletions(-) create mode 100644 .changeset/real-fireants-rule.md create mode 100644 core/cmd/aptos_keys_commands.go create mode 100644 core/cmd/aptos_keys_commands_test.go create mode 100644 core/services/keystore/aptos.go create mode 100644 core/services/keystore/aptos_test.go create mode 100644 core/services/keystore/keys/aptoskey/account.go create mode 100644 core/services/keystore/keys/aptoskey/account_test.go create mode 100644 core/services/keystore/keys/aptoskey/export.go create mode 100644 core/services/keystore/keys/aptoskey/export_test.go create mode 100644 core/services/keystore/keys/aptoskey/key.go create mode 100644 core/services/keystore/keys/ocr2key/aptos_keyring.go create mode 100644 core/services/keystore/keys/ocr2key/aptos_keyring_test.go create mode 100644 core/services/keystore/mocks/aptos.go create mode 100644 core/web/aptos_keys_controller.go create mode 100644 core/web/aptos_keys_controller_test.go create mode 100644 core/web/presenters/aptos_key.go create mode 100644 testdata/scripts/keys/aptos/help.txtar diff --git a/.changeset/real-fireants-rule.md b/.changeset/real-fireants-rule.md new file mode 100644 index 00000000000..4e3e83b484b --- /dev/null +++ b/.changeset/real-fireants-rule.md @@ -0,0 +1,5 @@ +--- +"chainlink": minor +--- + +#changed Added Aptos Keystore to Core. This includes Aptos Key which uses ED25519, Keystore, Relevant tests diff --git a/core/cmd/app.go b/core/cmd/app.go index 378f7e8bd64..94acfc4d74f 100644 --- a/core/cmd/app.go +++ b/core/cmd/app.go @@ -194,6 +194,7 @@ func NewApp(s *Shell) *cli.App { keysCommand("Cosmos", NewCosmosKeysClient(s)), keysCommand("Solana", NewSolanaKeysClient(s)), keysCommand("StarkNet", NewStarkNetKeysClient(s)), + keysCommand("Aptos", NewAptosKeysClient(s)), keysCommand("DKGSign", NewDKGSignKeysClient(s)), keysCommand("DKGEncrypt", NewDKGEncryptKeysClient(s)), diff --git a/core/cmd/aptos_keys_commands.go b/core/cmd/aptos_keys_commands.go new file mode 100644 index 00000000000..cabad347510 --- /dev/null +++ b/core/cmd/aptos_keys_commands.go @@ -0,0 +1,57 @@ +package cmd + +import ( + "github.com/smartcontractkit/chainlink-common/pkg/utils" + "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/aptoskey" + "github.com/smartcontractkit/chainlink/v2/core/web/presenters" +) + +type AptosKeyPresenter struct { + JAID + presenters.AptosKeyResource +} + +// RenderTable implements TableRenderer +func (p AptosKeyPresenter) RenderTable(rt RendererTable) error { + headers := []string{"ID", "Aptos Public Key"} + rows := [][]string{p.ToRow()} + + if _, err := rt.Write([]byte("🔑 Aptos Keys\n")); err != nil { + return err + } + renderList(headers, rows, rt.Writer) + + return utils.JustError(rt.Write([]byte("\n"))) +} + +func (p *AptosKeyPresenter) ToRow() []string { + row := []string{ + p.ID, + p.PubKey, + } + + return row +} + +type AptosKeyPresenters []AptosKeyPresenter + +// RenderTable implements TableRenderer +func (ps AptosKeyPresenters) RenderTable(rt RendererTable) error { + headers := []string{"ID", "Aptos Public Key"} + rows := [][]string{} + + for _, p := range ps { + rows = append(rows, p.ToRow()) + } + + if _, err := rt.Write([]byte("🔑 Aptos Keys\n")); err != nil { + return err + } + renderList(headers, rows, rt.Writer) + + return utils.JustError(rt.Write([]byte("\n"))) +} + +func NewAptosKeysClient(s *Shell) KeysClient { + return newKeysClient[aptoskey.Key, AptosKeyPresenter, AptosKeyPresenters]("Aptos", s) +} diff --git a/core/cmd/aptos_keys_commands_test.go b/core/cmd/aptos_keys_commands_test.go new file mode 100644 index 00000000000..c88a8e539a8 --- /dev/null +++ b/core/cmd/aptos_keys_commands_test.go @@ -0,0 +1,175 @@ +package cmd_test + +import ( + "bytes" + "context" + "flag" + "fmt" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/urfave/cli" + + "github.com/smartcontractkit/chainlink-common/pkg/utils" + "github.com/smartcontractkit/chainlink/v2/core/cmd" + "github.com/smartcontractkit/chainlink/v2/core/internal/cltest" + "github.com/smartcontractkit/chainlink/v2/core/internal/testutils" + "github.com/smartcontractkit/chainlink/v2/core/services/chainlink" + "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/aptoskey" + "github.com/smartcontractkit/chainlink/v2/core/web/presenters" +) + +func TestAptosKeyPresenter_RenderTable(t *testing.T) { + t.Parallel() + + var ( + id = "1" + pubKey = "somepubkey" + buffer = bytes.NewBufferString("") + r = cmd.RendererTable{Writer: buffer} + ) + + p := cmd.AptosKeyPresenter{ + JAID: cmd.JAID{ID: id}, + AptosKeyResource: presenters.AptosKeyResource{ + JAID: presenters.NewJAID(id), + PubKey: pubKey, + }, + } + + // Render a single resource + require.NoError(t, p.RenderTable(r)) + + output := buffer.String() + assert.Contains(t, output, id) + assert.Contains(t, output, pubKey) + + // Render many resources + buffer.Reset() + ps := cmd.AptosKeyPresenters{p} + require.NoError(t, ps.RenderTable(r)) + + output = buffer.String() + assert.Contains(t, output, id) + assert.Contains(t, output, pubKey) +} + +func TestShell_AptosKeys(t *testing.T) { + app := startNewApplicationV2(t, nil) + ks := app.GetKeyStore().Aptos() + cleanup := func() { + ctx := context.Background() + keys, err := ks.GetAll() + require.NoError(t, err) + for _, key := range keys { + require.NoError(t, utils.JustError(ks.Delete(ctx, key.ID()))) + } + requireAptosKeyCount(t, app, 0) + } + + t.Run("ListAptosKeys", func(tt *testing.T) { + defer cleanup() + ctx := testutils.Context(t) + client, r := app.NewShellAndRenderer() + key, err := app.GetKeyStore().Aptos().Create(ctx) + require.NoError(t, err) + requireAptosKeyCount(t, app, 1) + assert.Nil(t, cmd.NewAptosKeysClient(client).ListKeys(cltest.EmptyCLIContext())) + require.Equal(t, 1, len(r.Renders)) + keys := *r.Renders[0].(*cmd.AptosKeyPresenters) + assert.True(t, key.PublicKeyStr() == keys[0].PubKey) + }) + + t.Run("CreateAptosKey", func(tt *testing.T) { + defer cleanup() + client, _ := app.NewShellAndRenderer() + require.NoError(t, cmd.NewAptosKeysClient(client).CreateKey(nilContext)) + keys, err := app.GetKeyStore().Aptos().GetAll() + require.NoError(t, err) + require.Len(t, keys, 1) + }) + + t.Run("DeleteAptosKey", func(tt *testing.T) { + defer cleanup() + ctx := testutils.Context(t) + client, _ := app.NewShellAndRenderer() + key, err := app.GetKeyStore().Aptos().Create(ctx) + require.NoError(t, err) + requireAptosKeyCount(t, app, 1) + set := flag.NewFlagSet("test", 0) + flagSetApplyFromAction(cmd.NewAptosKeysClient(client).DeleteKey, set, "aptos") + + require.NoError(tt, set.Set("yes", "true")) + + strID := key.ID() + err = set.Parse([]string{strID}) + require.NoError(t, err) + c := cli.NewContext(nil, set, nil) + err = cmd.NewAptosKeysClient(client).DeleteKey(c) + require.NoError(t, err) + requireAptosKeyCount(t, app, 0) + }) + + t.Run("ImportExportAptosKey", func(tt *testing.T) { + defer cleanup() + defer deleteKeyExportFile(t) + ctx := testutils.Context(t) + client, _ := app.NewShellAndRenderer() + + _, err := app.GetKeyStore().Aptos().Create(ctx) + require.NoError(t, err) + + keys := requireAptosKeyCount(t, app, 1) + key := keys[0] + keyName := keyNameForTest(t) + + // Export test invalid id + set := flag.NewFlagSet("test Aptos export", 0) + flagSetApplyFromAction(cmd.NewAptosKeysClient(client).ExportKey, set, "aptos") + + require.NoError(tt, set.Parse([]string{"0"})) + require.NoError(tt, set.Set("new-password", "../internal/fixtures/incorrect_password.txt")) + require.NoError(tt, set.Set("output", keyName)) + + c := cli.NewContext(nil, set, nil) + err = cmd.NewAptosKeysClient(client).ExportKey(c) + require.Error(t, err, "Error exporting") + require.Error(t, utils.JustError(os.Stat(keyName))) + + // Export test + set = flag.NewFlagSet("test Aptos export", 0) + flagSetApplyFromAction(cmd.NewAptosKeysClient(client).ExportKey, set, "aptos") + + require.NoError(tt, set.Parse([]string{fmt.Sprint(key.ID())})) + require.NoError(tt, set.Set("new-password", "../internal/fixtures/incorrect_password.txt")) + require.NoError(tt, set.Set("output", keyName)) + + c = cli.NewContext(nil, set, nil) + + require.NoError(t, cmd.NewAptosKeysClient(client).ExportKey(c)) + require.NoError(t, utils.JustError(os.Stat(keyName))) + + require.NoError(t, utils.JustError(app.GetKeyStore().Aptos().Delete(ctx, key.ID()))) + requireAptosKeyCount(t, app, 0) + + set = flag.NewFlagSet("test Aptos import", 0) + flagSetApplyFromAction(cmd.NewAptosKeysClient(client).ImportKey, set, "aptos") + + require.NoError(tt, set.Parse([]string{keyName})) + require.NoError(tt, set.Set("old-password", "../internal/fixtures/incorrect_password.txt")) + c = cli.NewContext(nil, set, nil) + require.NoError(t, cmd.NewAptosKeysClient(client).ImportKey(c)) + + requireAptosKeyCount(t, app, 1) + }) +} + +func requireAptosKeyCount(t *testing.T, app chainlink.Application, length int) []aptoskey.Key { + t.Helper() + keys, err := app.GetKeyStore().Aptos().GetAll() + require.NoError(t, err) + require.Len(t, keys, length) + return keys +} diff --git a/core/config/app_config.go b/core/config/app_config.go index 869477218db..112e242636f 100644 --- a/core/config/app_config.go +++ b/core/config/app_config.go @@ -25,6 +25,7 @@ type AppConfig interface { CosmosEnabled() bool SolanaEnabled() bool StarkNetEnabled() bool + AptosEnabled() bool Validate() error ValidateDB() error diff --git a/core/internal/cltest/cltest.go b/core/internal/cltest/cltest.go index e86394d82f9..724af71b4af 100644 --- a/core/internal/cltest/cltest.go +++ b/core/internal/cltest/cltest.go @@ -66,6 +66,7 @@ import ( "github.com/smartcontractkit/chainlink/v2/core/services/chainlink" "github.com/smartcontractkit/chainlink/v2/core/services/job" "github.com/smartcontractkit/chainlink/v2/core/services/keystore" + "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/aptoskey" "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/cosmoskey" "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/csakey" "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/dkgencryptkey" @@ -126,6 +127,7 @@ var ( DefaultP2PKey = p2pkey.MustNewV2XXXTestingOnly(big.NewInt(KeyBigIntSeed)) DefaultSolanaKey = solkey.MustNewInsecure(keystest.NewRandReaderFromSeed(KeyBigIntSeed)) DefaultStarkNetKey = starkkey.MustNewInsecure(keystest.NewRandReaderFromSeed(KeyBigIntSeed)) + DefaultAptosKey = aptoskey.MustNewInsecure(keystest.NewRandReaderFromSeed(KeyBigIntSeed)) DefaultVRFKey = vrfkey.MustNewV2XXXTestingOnly(big.NewInt(KeyBigIntSeed)) DefaultDKGSignKey = dkgsignkey.MustNewXXXTestingOnly(big.NewInt(KeyBigIntSeed)) DefaultDKGEncryptKey = dkgencryptkey.MustNewXXXTestingOnly(big.NewInt(KeyBigIntSeed)) diff --git a/core/services/chainlink/config.go b/core/services/chainlink/config.go index 200d4973ed1..dc301c0967f 100644 --- a/core/services/chainlink/config.go +++ b/core/services/chainlink/config.go @@ -42,6 +42,32 @@ type Config struct { Solana solcfg.TOMLConfigs `toml:",omitempty"` Starknet stkcfg.TOMLConfigs `toml:",omitempty"` + + Aptos RawConfigs `toml:",omitempty"` +} + +// RawConfigs is a list of RawConfig. +type RawConfigs []RawConfig + +// RawConfig is the config used for chains that are not embedded. +type RawConfig map[string]any + +// ValidateConfig returns an error if the Config is not valid for use, as-is. +func (c *RawConfig) ValidateConfig() (err error) { + if v, ok := (*c)["Enabled"]; ok { + if _, ok := v.(*bool); !ok { + err = multierr.Append(err, commonconfig.ErrInvalid{Name: "Enabled", Value: v, Msg: "expected *bool"}) + } + } + return err +} + +func (c *RawConfig) IsEnabled() bool { + if c == nil { + return false + } + + return (*c)["Enabled"] == nil || *(*c)["Enabled"].(*bool) } // TOMLString returns a TOML encoded string. diff --git a/core/services/chainlink/config_general.go b/core/services/chainlink/config_general.go index 5b4a6271d52..5b6b839fb5e 100644 --- a/core/services/chainlink/config_general.go +++ b/core/services/chainlink/config_general.go @@ -345,6 +345,15 @@ func (g *generalConfig) StarkNetEnabled() bool { return false } +func (g *generalConfig) AptosEnabled() bool { + for _, c := range g.c.Aptos { + if c.IsEnabled() { + return true + } + } + return false +} + func (g *generalConfig) WebServer() config.WebServer { return &webServerConfig{c: g.c.WebServer, s: g.secrets.WebServer, rootDir: g.RootDir} } diff --git a/core/services/chainlink/config_test.go b/core/services/chainlink/config_test.go index 4db8fbc9482..576a932e3c6 100644 --- a/core/services/chainlink/config_test.go +++ b/core/services/chainlink/config_test.go @@ -1264,7 +1264,7 @@ func TestConfig_Validate(t *testing.T) { toml string exp string }{ - {name: "invalid", toml: invalidTOML, exp: `invalid configuration: 7 errors: + {name: "invalid", toml: invalidTOML, exp: `invalid configuration: 8 errors: - P2P.V2.Enabled: invalid value (false): P2P required for OCR or OCR2. Please enable P2P or disable OCR/OCR2. - Database.Lock.LeaseRefreshInterval: invalid value (6s): must be less than or equal to half of LeaseDuration (10s) - WebServer: 8 errors: @@ -1357,7 +1357,8 @@ func TestConfig_Validate(t *testing.T) { - 0.ChainID: missing: required for all chains - 1: 2 errors: - ChainID: missing: required for all chains - - Nodes: missing: must have at least one node`}, + - Nodes: missing: must have at least one node + - Aptos.0.Enabled: invalid value (1): expected *bool`}, } { t.Run(tt.name, func(t *testing.T) { var c Config diff --git a/core/services/chainlink/mocks/general_config.go b/core/services/chainlink/mocks/general_config.go index 50a4478d7c8..6c11a1fe3a0 100644 --- a/core/services/chainlink/mocks/general_config.go +++ b/core/services/chainlink/mocks/general_config.go @@ -46,6 +46,24 @@ func (_m *GeneralConfig) AppID() uuid.UUID { return r0 } +// AptosEnabled provides a mock function with given fields: +func (_m *GeneralConfig) AptosEnabled() bool { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for AptosEnabled") + } + + var r0 bool + if rf, ok := ret.Get(0).(func() bool); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} + // AuditLogger provides a mock function with given fields: func (_m *GeneralConfig) AuditLogger() config.AuditLogger { ret := _m.Called() diff --git a/core/services/chainlink/testdata/config-invalid.toml b/core/services/chainlink/testdata/config-invalid.toml index 30fbfff4729..ca22e68c22c 100644 --- a/core/services/chainlink/testdata/config-invalid.toml +++ b/core/services/chainlink/testdata/config-invalid.toml @@ -159,6 +159,9 @@ APIKey = 'key' [[Starknet]] +[[Aptos]] +Enabled = 1 + [OCR2] Enabled = true diff --git a/core/services/job/job_orm_test.go b/core/services/job/job_orm_test.go index 764c517e8e4..6defdeeb614 100644 --- a/core/services/job/job_orm_test.go +++ b/core/services/job/job_orm_test.go @@ -997,6 +997,18 @@ func TestORM_ValidateKeyStoreMatch(t *testing.T) { require.NoError(t, err) }) + t.Run(("test Aptos key validation"), func(t *testing.T) { + ctx := testutils.Context(t) + jb.OCR2OracleSpec.Relay = relay.NetworkAptos + err := job.ValidateKeyStoreMatch(ctx, jb.OCR2OracleSpec, keyStore, "bad key") + require.EqualError(t, err, "no Aptos key matching: \"bad key\"") + + aptosKey, err := keyStore.Aptos().Create(ctx) + require.NoError(t, err) + err = job.ValidateKeyStoreMatch(ctx, jb.OCR2OracleSpec, keyStore, aptosKey.ID()) + require.NoError(t, err) + }) + t.Run("test Mercury ETH key validation", func(t *testing.T) { ctx := testutils.Context(t) jb.OCR2OracleSpec.PluginType = types.Mercury diff --git a/core/services/job/orm.go b/core/services/job/orm.go index de531985465..3f2d5c237d7 100644 --- a/core/services/job/orm.go +++ b/core/services/job/orm.go @@ -602,8 +602,10 @@ func validateKeyStoreMatchForRelay(ctx context.Context, network string, keyStore return errors.Errorf("no Starknet key matching: %q", key) } case relay.NetworkAptos: - // TODO BCI-2953 - return nil + _, err := keyStore.Aptos().Get(key) + if err != nil { + return errors.Errorf("no Aptos key matching: %q", key) + } } return nil } diff --git a/core/services/keystore/aptos.go b/core/services/keystore/aptos.go new file mode 100644 index 00000000000..4a40033ae0d --- /dev/null +++ b/core/services/keystore/aptos.go @@ -0,0 +1,182 @@ +package keystore + +import ( + "context" + "fmt" + + "github.com/pkg/errors" + + "github.com/smartcontractkit/chainlink-common/pkg/loop" + "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/aptoskey" +) + +//go:generate mockery --quiet --name Aptos --output ./mocks/ --case=underscore --filename aptos.go + +type Aptos interface { + Get(id string) (aptoskey.Key, error) + GetAll() ([]aptoskey.Key, error) + Create(ctx context.Context) (aptoskey.Key, error) + Add(ctx context.Context, key aptoskey.Key) error + Delete(ctx context.Context, id string) (aptoskey.Key, error) + Import(ctx context.Context, keyJSON []byte, password string) (aptoskey.Key, error) + Export(id string, password string) ([]byte, error) + EnsureKey(ctx context.Context) error + Sign(ctx context.Context, id string, msg []byte) (signature []byte, err error) +} + +type aptos struct { + *keyManager +} + +var _ Aptos = &aptos{} + +func newAptosKeyStore(km *keyManager) *aptos { + return &aptos{ + km, + } +} + +func (ks *aptos) Get(id string) (aptoskey.Key, error) { + ks.lock.RLock() + defer ks.lock.RUnlock() + if ks.isLocked() { + return aptoskey.Key{}, ErrLocked + } + return ks.getByID(id) +} + +func (ks *aptos) GetAll() (keys []aptoskey.Key, _ error) { + ks.lock.RLock() + defer ks.lock.RUnlock() + if ks.isLocked() { + return nil, ErrLocked + } + for _, key := range ks.keyRing.Aptos { + keys = append(keys, key) + } + return keys, nil +} + +func (ks *aptos) Create(ctx context.Context) (aptoskey.Key, error) { + ks.lock.Lock() + defer ks.lock.Unlock() + if ks.isLocked() { + return aptoskey.Key{}, ErrLocked + } + key, err := aptoskey.New() + if err != nil { + return aptoskey.Key{}, err + } + return key, ks.safeAddKey(ctx, key) +} + +func (ks *aptos) Add(ctx context.Context, key aptoskey.Key) error { + ks.lock.Lock() + defer ks.lock.Unlock() + if ks.isLocked() { + return ErrLocked + } + if _, found := ks.keyRing.Aptos[key.ID()]; found { + return fmt.Errorf("key with ID %s already exists", key.ID()) + } + return ks.safeAddKey(ctx, key) +} + +func (ks *aptos) Delete(ctx context.Context, id string) (aptoskey.Key, error) { + ks.lock.Lock() + defer ks.lock.Unlock() + if ks.isLocked() { + return aptoskey.Key{}, ErrLocked + } + key, err := ks.getByID(id) + if err != nil { + return aptoskey.Key{}, err + } + err = ks.safeRemoveKey(ctx, key) + return key, err +} + +func (ks *aptos) Import(ctx context.Context, keyJSON []byte, password string) (aptoskey.Key, error) { + ks.lock.Lock() + defer ks.lock.Unlock() + if ks.isLocked() { + return aptoskey.Key{}, ErrLocked + } + key, err := aptoskey.FromEncryptedJSON(keyJSON, password) + if err != nil { + return aptoskey.Key{}, errors.Wrap(err, "AptosKeyStore#ImportKey failed to decrypt key") + } + if _, found := ks.keyRing.Aptos[key.ID()]; found { + return aptoskey.Key{}, fmt.Errorf("key with ID %s already exists", key.ID()) + } + return key, ks.keyManager.safeAddKey(ctx, key) +} + +func (ks *aptos) Export(id string, password string) ([]byte, error) { + ks.lock.RLock() + defer ks.lock.RUnlock() + if ks.isLocked() { + return nil, ErrLocked + } + key, err := ks.getByID(id) + if err != nil { + return nil, err + } + return key.ToEncryptedJSON(password, ks.scryptParams) +} + +func (ks *aptos) EnsureKey(ctx context.Context) error { + ks.lock.Lock() + defer ks.lock.Unlock() + if ks.isLocked() { + return ErrLocked + } + if len(ks.keyRing.Aptos) > 0 { + return nil + } + + key, err := aptoskey.New() + if err != nil { + return err + } + + ks.logger.Infof("Created Aptos key with ID %s", key.ID()) + + return ks.safeAddKey(ctx, key) +} + +func (ks *aptos) Sign(_ context.Context, id string, msg []byte) (signature []byte, err error) { + k, err := ks.Get(id) + if err != nil { + return nil, err + } + return k.Sign(msg) +} + +func (ks *aptos) getByID(id string) (aptoskey.Key, error) { + key, found := ks.keyRing.Aptos[id] + if !found { + return aptoskey.Key{}, KeyNotFoundError{ID: id, KeyType: "Aptos"} + } + return key, nil +} + +// AptosSigner implements [github.com/smartcontractkit/chainlink-common/pkg/loop.Keystore] interface and the requirements +// Handles signing for Apots Messages +type AptosLooppSigner struct { + Aptos +} + +var _ loop.Keystore = &AptosLooppSigner{} + +// Returns a list of Aptos Public Keys +func (s *AptosLooppSigner) Accounts(ctx context.Context) (accounts []string, err error) { + ks, err := s.GetAll() + if err != nil { + return nil, err + } + for _, k := range ks { + accounts = append(accounts, k.ID()) + } + return +} diff --git a/core/services/keystore/aptos_test.go b/core/services/keystore/aptos_test.go new file mode 100644 index 00000000000..9a2764adff6 --- /dev/null +++ b/core/services/keystore/aptos_test.go @@ -0,0 +1,138 @@ +package keystore_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-common/pkg/utils" + "github.com/smartcontractkit/chainlink/v2/core/internal/cltest" + "github.com/smartcontractkit/chainlink/v2/core/internal/testutils" + "github.com/smartcontractkit/chainlink/v2/core/internal/testutils/pgtest" + "github.com/smartcontractkit/chainlink/v2/core/services/keystore" + "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/aptoskey" +) + +func Test_AptosKeyStore_E2E(t *testing.T) { + db := pgtest.NewSqlxDB(t) + + keyStore := keystore.ExposedNewMaster(t, db) + require.NoError(t, keyStore.Unlock(testutils.Context(t), cltest.Password)) + ks := keyStore.Aptos() + reset := func() { + ctx := context.Background() // Executed on cleanup + require.NoError(t, utils.JustError(db.Exec("DELETE FROM encrypted_key_rings"))) + keyStore.ResetXXXTestOnly() + require.NoError(t, keyStore.Unlock(ctx, cltest.Password)) + } + + t.Run("initializes with an empty state", func(t *testing.T) { + defer reset() + keys, err := ks.GetAll() + require.NoError(t, err) + require.Equal(t, 0, len(keys)) + }) + + t.Run("errors when getting non-existent ID", func(t *testing.T) { + defer reset() + _, err := ks.Get("non-existent-id") + require.Error(t, err) + }) + + t.Run("creates a key", func(t *testing.T) { + defer reset() + ctx := testutils.Context(t) + key, err := ks.Create(ctx) + require.NoError(t, err) + retrievedKey, err := ks.Get(key.ID()) + require.NoError(t, err) + require.Equal(t, key, retrievedKey) + }) + + t.Run("imports and exports a key", func(t *testing.T) { + defer reset() + ctx := testutils.Context(t) + key, err := ks.Create(ctx) + require.NoError(t, err) + exportJSON, err := ks.Export(key.ID(), cltest.Password) + require.NoError(t, err) + _, err = ks.Export("non-existent", cltest.Password) + assert.Error(t, err) + _, err = ks.Delete(ctx, key.ID()) + require.NoError(t, err) + _, err = ks.Get(key.ID()) + require.Error(t, err) + importedKey, err := ks.Import(ctx, exportJSON, cltest.Password) + require.NoError(t, err) + _, err = ks.Import(ctx, exportJSON, cltest.Password) + assert.Error(t, err) + _, err = ks.Import(ctx, []byte(""), cltest.Password) + assert.Error(t, err) + require.Equal(t, key.ID(), importedKey.ID()) + retrievedKey, err := ks.Get(key.ID()) + require.NoError(t, err) + require.Equal(t, importedKey, retrievedKey) + }) + + t.Run("adds an externally created key / deletes a key", func(t *testing.T) { + defer reset() + ctx := testutils.Context(t) + newKey, err := aptoskey.New() + require.NoError(t, err) + err = ks.Add(ctx, newKey) + require.NoError(t, err) + err = ks.Add(ctx, newKey) + assert.Error(t, err) + keys, err := ks.GetAll() + require.NoError(t, err) + require.Equal(t, 1, len(keys)) + _, err = ks.Delete(ctx, newKey.ID()) + require.NoError(t, err) + _, err = ks.Delete(ctx, newKey.ID()) + assert.Error(t, err) + keys, err = ks.GetAll() + require.NoError(t, err) + require.Equal(t, 0, len(keys)) + _, err = ks.Get(newKey.ID()) + require.Error(t, err) + }) + + t.Run("ensures key", func(t *testing.T) { + defer reset() + ctx := testutils.Context(t) + err := ks.EnsureKey(ctx) + assert.NoError(t, err) + + err = ks.EnsureKey(ctx) + assert.NoError(t, err) + + keys, err := ks.GetAll() + require.NoError(t, err) + require.Equal(t, 1, len(keys)) + }) + + t.Run("sign tx", func(t *testing.T) { + defer reset() + ctx := testutils.Context(t) + newKey, err := aptoskey.New() + require.NoError(t, err) + require.NoError(t, ks.Add(ctx, newKey)) + + // sign unknown ID + _, err = ks.Sign(testutils.Context(t), "not-real", nil) + assert.Error(t, err) + + // sign known key + payload := []byte{1} + sig, err := ks.Sign(testutils.Context(t), newKey.ID(), payload) + require.NoError(t, err) + + directSig, err := newKey.Sign(payload) + require.NoError(t, err) + + // signatures should match using keystore sign or key sign + assert.Equal(t, directSig, sig) + }) +} diff --git a/core/services/keystore/chaintype/chaintype.go b/core/services/keystore/chaintype/chaintype.go index cd149e390ef..8a12322ebfe 100644 --- a/core/services/keystore/chaintype/chaintype.go +++ b/core/services/keystore/chaintype/chaintype.go @@ -19,6 +19,8 @@ const ( Solana ChainType = "solana" // StarkNet for the StarkNet chain StarkNet ChainType = "starknet" + // Aptos for the Aptos chain + Aptos ChainType = "aptos" ) type ChainTypes []ChainType @@ -35,7 +37,7 @@ func (c ChainTypes) String() (out string) { } // SupportedChainTypes contain all chains that are supported -var SupportedChainTypes = ChainTypes{EVM, Cosmos, Solana, StarkNet} +var SupportedChainTypes = ChainTypes{EVM, Cosmos, Solana, StarkNet, Aptos} // ErrInvalidChainType is an error to indicate an unsupported chain type var ErrInvalidChainType error diff --git a/core/services/keystore/keys/aptoskey/account.go b/core/services/keystore/keys/aptoskey/account.go new file mode 100644 index 00000000000..89f62d33011 --- /dev/null +++ b/core/services/keystore/keys/aptoskey/account.go @@ -0,0 +1,72 @@ +package aptoskey + +import ( + "encoding/hex" + "errors" + "fmt" + "strings" +) + +// AccountAddress is a 32 byte address on the Aptos blockchain +// It can represent an Object, an Account, and much more. +// +// AccountAddress is copied from the aptos sdk because: +// 1. There are still breaking changes in sdk and we don't want the dependency. +// 2. AccountAddress is just a wrapper and can be easily extracted out. +// +// https://github.com/aptos-labs/aptos-go-sdk/blob/main/internal/types/account.go +type AccountAddress [32]byte + +// IsSpecial Returns whether the address is a "special" address. Addresses are considered +// special if the first 63 characters of the hex string are zero. In other words, +// an address is special if the first 31 bytes are zero and the last byte is +// smaller than `0b10000` (16). In other words, special is defined as an address +// that matches the following regex: `^0x0{63}[0-9a-f]$`. In short form this means +// the addresses in the range from `0x0` to `0xf` (inclusive) are special. +// For more details see the v1 address standard defined as part of AIP-40: +// https://github.com/aptos-foundation/AIPs/blob/main/aips/aip-40.md +func (aa *AccountAddress) IsSpecial() bool { + for _, b := range aa[:31] { + if b != 0 { + return false + } + } + return aa[31] < 0x10 +} + +// String Returns the canonical string representation of the AccountAddress +func (aa *AccountAddress) String() string { + if aa.IsSpecial() { + return fmt.Sprintf("0x%x", aa[31]) + } + return BytesToHex(aa[:]) +} + +// ParseStringRelaxed parses a string into an AccountAddress +func (aa *AccountAddress) ParseStringRelaxed(x string) error { + x = strings.TrimPrefix(x, "0x") + if len(x) < 1 { + return ErrAddressTooShort + } + if len(x) > 64 { + return ErrAddressTooLong + } + if len(x)%2 != 0 { + x = "0" + x + } + bytes, err := hex.DecodeString(x) + if err != nil { + return err + } + // zero-prefix/right-align what bytes we got + copy((*aa)[32-len(bytes):], bytes) + + return nil +} + +var ErrAddressTooShort = errors.New("AccountAddress too short") +var ErrAddressTooLong = errors.New("AccountAddress too long") + +func BytesToHex(bytes []byte) string { + return "0x" + hex.EncodeToString(bytes) +} diff --git a/core/services/keystore/keys/aptoskey/account_test.go b/core/services/keystore/keys/aptoskey/account_test.go new file mode 100644 index 00000000000..b9ed4ea04a5 --- /dev/null +++ b/core/services/keystore/keys/aptoskey/account_test.go @@ -0,0 +1,141 @@ +package aptoskey + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// Tests extracted from +// https://github.com/aptos-labs/aptos-go-sdk/blob/5ee5ac308e5881b508c0a5124f5e0b8df27a4d40/internal/types/account_test.go + +func TestStringOutput(t *testing.T) { + inputs := [][]byte{ + {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01}, + {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0F}, + {0x12, 0x34, 0x12, 0x34, 0x12, 0x34, 0x12, 0x34, 0x12, 0x34, 0x12, 0x34, 0x12, 0x34, 0x12, 0x34, 0x12, 0x34, 0x12, 0x34, 0x12, 0x34, 0x12, 0x34, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef}, + {0x02, 0x34, 0x12, 0x34, 0x12, 0x34, 0x12, 0x34, 0x12, 0x34, 0x12, 0x34, 0x12, 0x34, 0x12, 0x34, 0x12, 0x34, 0x12, 0x34, 0x12, 0x34, 0x12, 0x34, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef}, + {0x00, 0x34, 0x12, 0x34, 0x12, 0x34, 0x12, 0x34, 0x12, 0x34, 0x12, 0x34, 0x12, 0x34, 0x12, 0x34, 0x12, 0x34, 0x12, 0x34, 0x12, 0x34, 0x12, 0x34, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef}, + {0x00, 0x04, 0x12, 0x34, 0x12, 0x34, 0x12, 0x34, 0x12, 0x34, 0x12, 0x34, 0x12, 0x34, 0x12, 0x34, 0x12, 0x34, 0x12, 0x34, 0x12, 0x34, 0x12, 0x34, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef}, + {0x00, 0x00, 0x12, 0x34, 0x12, 0x34, 0x12, 0x34, 0x12, 0x34, 0x12, 0x34, 0x12, 0x34, 0x12, 0x34, 0x12, 0x34, 0x12, 0x34, 0x12, 0x34, 0x12, 0x34, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef}, + } + expected := []string{ + "0x0", + "0x1", + "0xf", + "0x1234123412341234123412341234123412341234123412340123456789abcdef", + "0x0234123412341234123412341234123412341234123412340123456789abcdef", + "0x0034123412341234123412341234123412341234123412340123456789abcdef", + "0x0004123412341234123412341234123412341234123412340123456789abcdef", + "0x0000123412341234123412341234123412341234123412340123456789abcdef", + } + + for i := 0; i < len(inputs); i++ { + addr := AccountAddress(inputs[i]) + assert.Equal(t, expected[i], addr.String()) + } +} + +func TestAccountAddress_ParseStringRelaxed_Error(t *testing.T) { + var owner AccountAddress + err := owner.ParseStringRelaxed("0x") + assert.Error(t, err) + err = owner.ParseStringRelaxed("0xF1234567812345678123456781234567812345678123456781234567812345678") + assert.Error(t, err) + err = owner.ParseStringRelaxed("NotHex") + assert.Error(t, err) +} + +func TestAccountAddress_String(t *testing.T) { + testCases := []struct { + name string + address AccountAddress + expected string + }{ + { + name: "Special address", + address: AccountAddress{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01}, + expected: "0x1", + }, + { + name: "Non-special address", + address: AccountAddress{0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, 0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, 0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, 0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0}, + expected: "0x123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expected, tc.address.String()) + }) + } +} + +func TestAccountAddress_IsSpecial(t *testing.T) { + testCases := []struct { + name string + address AccountAddress + expected bool + }{ + { + name: "Special address", + address: AccountAddress{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01}, + expected: true, + }, + { + name: "Non-special address", + address: AccountAddress{0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, 0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, 0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, 0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0}, + expected: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expected, tc.address.IsSpecial()) + }) + } +} + +func TestBytesToHex(t *testing.T) { + testCases := []struct { + name string + bytes []byte + expected string + }{ + { + name: "Empty bytes", + bytes: []byte{}, + expected: "0x", + }, + { + name: "Non-empty bytes", + bytes: []byte{0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0}, + expected: "0x123456789abcdef0", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expected, BytesToHex(tc.bytes)) + }) + } +} + +func TestAccountSpecialString(t *testing.T) { + var aa AccountAddress + aa[31] = 3 + aas := aa.String() + if aas != "0x3" { + t.Errorf("wanted 0x3 got %s", aas) + } + + var aa2 AccountAddress + err := aa2.ParseStringRelaxed("0x3") + if err != nil { + t.Errorf("unexpected err %s", err) + } + if aa2 != aa { + t.Errorf("aa2 != aa") + } +} diff --git a/core/services/keystore/keys/aptoskey/export.go b/core/services/keystore/keys/aptoskey/export.go new file mode 100644 index 00000000000..23553ef9dca --- /dev/null +++ b/core/services/keystore/keys/aptoskey/export.go @@ -0,0 +1,48 @@ +package aptoskey + +import ( + "encoding/hex" + + "github.com/ethereum/go-ethereum/accounts/keystore" + + "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys" + "github.com/smartcontractkit/chainlink/v2/core/utils" +) + +const keyTypeIdentifier = "Aptos" + +// FromEncryptedJSON gets key from json and password +func FromEncryptedJSON(keyJSON []byte, password string) (Key, error) { + return keys.FromEncryptedJSON( + keyTypeIdentifier, + keyJSON, + password, + adulteratedPassword, + func(_ keys.EncryptedKeyExport, rawPrivKey []byte) (Key, error) { + return Raw(rawPrivKey).Key(), nil + }, + ) +} + +// ToEncryptedJSON returns encrypted JSON representing key +func (key Key) ToEncryptedJSON(password string, scryptParams utils.ScryptParams) (export []byte, err error) { + return keys.ToEncryptedJSON( + keyTypeIdentifier, + key.Raw(), + key, + password, + scryptParams, + adulteratedPassword, + func(id string, key Key, cryptoJSON keystore.CryptoJSON) keys.EncryptedKeyExport { + return keys.EncryptedKeyExport{ + KeyType: id, + PublicKey: hex.EncodeToString(key.pubKey), + Crypto: cryptoJSON, + } + }, + ) +} + +func adulteratedPassword(password string) string { + return "aptoskey" + password +} diff --git a/core/services/keystore/keys/aptoskey/export_test.go b/core/services/keystore/keys/aptoskey/export_test.go new file mode 100644 index 00000000000..a920e432298 --- /dev/null +++ b/core/services/keystore/keys/aptoskey/export_test.go @@ -0,0 +1,19 @@ +package aptoskey + +import ( + "testing" + + "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys" +) + +func TestAptosKeys_ExportImport(t *testing.T) { + keys.RunKeyExportImportTestcase(t, createKey, decryptKey) +} + +func createKey() (keys.KeyType, error) { + return New() +} + +func decryptKey(keyJSON []byte, password string) (keys.KeyType, error) { + return FromEncryptedJSON(keyJSON, password) +} diff --git a/core/services/keystore/keys/aptoskey/key.go b/core/services/keystore/keys/aptoskey/key.go new file mode 100644 index 00000000000..ec9b27a3596 --- /dev/null +++ b/core/services/keystore/keys/aptoskey/key.go @@ -0,0 +1,103 @@ +package aptoskey + +import ( + "crypto" + "crypto/ed25519" + crypto_rand "crypto/rand" + "fmt" + "io" + + "github.com/mr-tron/base58" +) + +// Raw represents the Aptos private key +type Raw []byte + +// Key gets the Key +func (raw Raw) Key() Key { + privKey := ed25519.NewKeyFromSeed(raw) + pubKey := privKey.Public().(ed25519.PublicKey) + return Key{ + privkey: privKey, + pubKey: pubKey, + } +} + +// String returns description +func (raw Raw) String() string { + return "" +} + +// GoString wraps String() +func (raw Raw) GoString() string { + return raw.String() +} + +var _ fmt.GoStringer = &Key{} + +// Key represents Aptos key +type Key struct { + privkey ed25519.PrivateKey + pubKey ed25519.PublicKey +} + +// New creates new Key +func New() (Key, error) { + return newFrom(crypto_rand.Reader) +} + +// MustNewInsecure return Key if no error +func MustNewInsecure(reader io.Reader) Key { + key, err := newFrom(reader) + if err != nil { + panic(err) + } + return key +} + +// newFrom creates new Key from a provided random reader +func newFrom(reader io.Reader) (Key, error) { + pub, priv, err := ed25519.GenerateKey(reader) + if err != nil { + return Key{}, err + } + return Key{ + privkey: priv, + pubKey: pub, + }, nil +} + +// ID gets Key ID +func (key Key) ID() string { + return key.PublicKeyStr() +} + +// GetPublic get Key's public key +func (key Key) GetPublic() ed25519.PublicKey { + return key.pubKey +} + +// PublicKeyStr return base58 encoded public key +func (key Key) PublicKeyStr() string { + return base58.Encode(key.pubKey) +} + +// Raw returns the seed from private key +func (key Key) Raw() Raw { + return key.privkey.Seed() +} + +// String is the print-friendly format of the Key +func (key Key) String() string { + return fmt.Sprintf("AptosKey{PrivateKey: , Public Key: %s}", key.PublicKeyStr()) +} + +// GoString wraps String() +func (key Key) GoString() string { + return key.String() +} + +// Sign is used to sign a message +func (key Key) Sign(msg []byte) ([]byte, error) { + return key.privkey.Sign(crypto_rand.Reader, msg, crypto.Hash(0)) // no specific hash function used +} diff --git a/core/services/keystore/keys/ocr2key/aptos_keyring.go b/core/services/keystore/keys/ocr2key/aptos_keyring.go new file mode 100644 index 00000000000..51e6c3a9c8f --- /dev/null +++ b/core/services/keystore/keys/ocr2key/aptos_keyring.go @@ -0,0 +1,118 @@ +package ocr2key + +import ( + "crypto/ed25519" + "encoding/binary" + "io" + + "github.com/hdevalence/ed25519consensus" + "github.com/pkg/errors" + "golang.org/x/crypto/blake2s" + + "github.com/smartcontractkit/chainlink/v2/core/utils" + + "github.com/smartcontractkit/libocr/offchainreporting2/types" + "github.com/smartcontractkit/libocr/offchainreporting2plus/chains/evmutil" + ocrtypes "github.com/smartcontractkit/libocr/offchainreporting2plus/types" +) + +var _ ocrtypes.OnchainKeyring = &aptosKeyring{} + +type aptosKeyring struct { + privKey ed25519.PrivateKey + pubKey ed25519.PublicKey +} + +func newAptosKeyring(material io.Reader) (*aptosKeyring, error) { + pubKey, privKey, err := ed25519.GenerateKey(material) + if err != nil { + return nil, err + } + return &aptosKeyring{pubKey: pubKey, privKey: privKey}, nil +} + +func (akr *aptosKeyring) PublicKey() ocrtypes.OnchainPublicKey { + return []byte(akr.pubKey) +} + +func (akr *aptosKeyring) reportToSigData(reportCtx ocrtypes.ReportContext, report ocrtypes.Report) ([]byte, error) { + rawReportContext := evmutil.RawReportContext(reportCtx) + h, err := blake2s.New256(nil) + if err != nil { + return nil, err + } + reportLen := make([]byte, 4) + binary.BigEndian.PutUint32(reportLen[0:], uint32(len(report))) + h.Write(reportLen[:]) + h.Write(report) + h.Write(rawReportContext[0][:]) + h.Write(rawReportContext[1][:]) + h.Write(rawReportContext[2][:]) + return h.Sum(nil), nil +} + +func (akr *aptosKeyring) Sign(reportCtx ocrtypes.ReportContext, report ocrtypes.Report) ([]byte, error) { + sigData, err := akr.reportToSigData(reportCtx, report) + if err != nil { + return nil, err + } + return akr.signBlob(sigData) +} + +func (akr *aptosKeyring) Sign3(digest types.ConfigDigest, seqNr uint64, r ocrtypes.Report) (signature []byte, err error) { + return nil, errors.New("not implemented") +} + +func (akr *aptosKeyring) signBlob(b []byte) ([]byte, error) { + signedMsg := ed25519.Sign(akr.privKey, b) + // match on-chain parsing (first 32 bytes are for pubkey, remaining are for signature) + return utils.ConcatBytes(akr.PublicKey(), signedMsg), nil +} + +func (akr *aptosKeyring) Verify(publicKey ocrtypes.OnchainPublicKey, reportCtx ocrtypes.ReportContext, report ocrtypes.Report, signature []byte) bool { + hash, err := akr.reportToSigData(reportCtx, report) + if err != nil { + return false + } + return akr.verifyBlob(publicKey, hash, signature) +} + +func (akr *aptosKeyring) Verify3(publicKey ocrtypes.OnchainPublicKey, cd ocrtypes.ConfigDigest, seqNr uint64, r ocrtypes.Report, signature []byte) bool { + return false +} + +func (akr *aptosKeyring) verifyBlob(pubkey ocrtypes.OnchainPublicKey, b, sig []byte) bool { + // Ed25519 signatures are always 64 bytes and the + // public key (always prefixed, see Sign above) is always, + // 32 bytes, so we always require the max signature length. + if len(sig) != akr.MaxSignatureLength() { + return false + } + if len(pubkey) != ed25519.PublicKeySize { + return false + } + return ed25519consensus.Verify(ed25519.PublicKey(pubkey), b, sig[32:]) +} + +func (akr *aptosKeyring) MaxSignatureLength() int { + // Reference: https://pkg.go.dev/crypto/ed25519 + return ed25519.PublicKeySize + ed25519.SignatureSize // 32 + 64 +} + +func (akr *aptosKeyring) Marshal() ([]byte, error) { + return akr.privKey.Seed(), nil +} + +func (akr *aptosKeyring) Unmarshal(in []byte) error { + if len(in) != ed25519.SeedSize { + return errors.Errorf("unexpected seed size, got %d want %d", len(in), ed25519.SeedSize) + } + privKey := ed25519.NewKeyFromSeed(in) + akr.privKey = privKey + pubKey, ok := privKey.Public().(ed25519.PublicKey) + if !ok { + return errors.New("failed to cast public key to ed25519.PublicKey") + } + akr.pubKey = pubKey + return nil +} diff --git a/core/services/keystore/keys/ocr2key/aptos_keyring_test.go b/core/services/keystore/keys/ocr2key/aptos_keyring_test.go new file mode 100644 index 00000000000..2b88d61065c --- /dev/null +++ b/core/services/keystore/keys/ocr2key/aptos_keyring_test.go @@ -0,0 +1,58 @@ +package ocr2key + +import ( + "bytes" + cryptorand "crypto/rand" + "testing" + + "github.com/stretchr/testify/assert" + + ocrtypes "github.com/smartcontractkit/libocr/offchainreporting2plus/types" + "github.com/stretchr/testify/require" +) + +func TestAptosKeyRing_Sign_Verify(t *testing.T) { + kr1, err := newAptosKeyring(cryptorand.Reader) + require.NoError(t, err) + kr2, err := newAptosKeyring(cryptorand.Reader) + require.NoError(t, err) + ctx := ocrtypes.ReportContext{} + + t.Run("can verify", func(t *testing.T) { + report := ocrtypes.Report{} + sig, err := kr1.Sign(ctx, report) + require.NoError(t, err) + t.Log(len(sig)) + result := kr2.Verify(kr1.PublicKey(), ctx, report, sig) + require.True(t, result) + }) + + t.Run("invalid sig", func(t *testing.T) { + report := ocrtypes.Report{} + result := kr2.Verify(kr1.PublicKey(), ctx, report, []byte{0x01}) + require.False(t, result) + }) + + t.Run("invalid pubkey", func(t *testing.T) { + report := ocrtypes.Report{} + sig, err := kr1.Sign(ctx, report) + require.NoError(t, err) + result := kr2.Verify([]byte{0x01}, ctx, report, sig) + require.False(t, result) + }) +} + +func TestAptosKeyRing_Marshalling(t *testing.T) { + kr1, err := newAptosKeyring(cryptorand.Reader) + require.NoError(t, err) + m, err := kr1.Marshal() + require.NoError(t, err) + kr2 := aptosKeyring{} + err = kr2.Unmarshal(m) + require.NoError(t, err) + assert.True(t, bytes.Equal(kr1.pubKey, kr2.pubKey)) + assert.True(t, bytes.Equal(kr1.privKey, kr2.privKey)) + + // Invalid seed size should error + require.Error(t, kr2.Unmarshal([]byte{0x01})) +} diff --git a/core/services/keystore/keys/ocr2key/cosmos_keyring.go b/core/services/keystore/keys/ocr2key/cosmos_keyring.go index 5f1b9b98198..1d1a477f2d0 100644 --- a/core/services/keystore/keys/ocr2key/cosmos_keyring.go +++ b/core/services/keystore/keys/ocr2key/cosmos_keyring.go @@ -7,12 +7,13 @@ import ( "github.com/hdevalence/ed25519consensus" "github.com/pkg/errors" - "github.com/smartcontractkit/libocr/offchainreporting2/types" - "github.com/smartcontractkit/libocr/offchainreporting2plus/chains/evmutil" - ocrtypes "github.com/smartcontractkit/libocr/offchainreporting2plus/types" "golang.org/x/crypto/blake2s" "github.com/smartcontractkit/chainlink/v2/core/utils" + + "github.com/smartcontractkit/libocr/offchainreporting2/types" + "github.com/smartcontractkit/libocr/offchainreporting2plus/chains/evmutil" + ocrtypes "github.com/smartcontractkit/libocr/offchainreporting2plus/types" ) var _ ocrtypes.OnchainKeyring = &cosmosKeyring{} @@ -108,6 +109,10 @@ func (ckr *cosmosKeyring) Unmarshal(in []byte) error { } privKey := ed25519.NewKeyFromSeed(in) ckr.privKey = privKey - ckr.pubKey = privKey.Public().(ed25519.PublicKey) + pubKey, ok := privKey.Public().(ed25519.PublicKey) + if !ok { + return errors.New("failed to cast public key to ed25519.PublicKey") + } + ckr.pubKey = pubKey return nil } diff --git a/core/services/keystore/keys/ocr2key/export.go b/core/services/keystore/keys/ocr2key/export.go index 5487e310462..8fa5ffedfed 100644 --- a/core/services/keystore/keys/ocr2key/export.go +++ b/core/services/keystore/keys/ocr2key/export.go @@ -46,6 +46,8 @@ func FromEncryptedJSON(keyJSON []byte, password string) (KeyBundle, error) { kb = newKeyBundle(new(solanaKeyring)) case chaintype.StarkNet: kb = newKeyBundle(new(starkkey.OCR2Key)) + case chaintype.Aptos: + kb = newKeyBundle(new(aptosKeyring)) default: return nil, chaintype.NewErrInvalidChainType(export.ChainType) } diff --git a/core/services/keystore/keys/ocr2key/export_test.go b/core/services/keystore/keys/ocr2key/export_test.go index dfa820eb2be..b0ffa2db009 100644 --- a/core/services/keystore/keys/ocr2key/export_test.go +++ b/core/services/keystore/keys/ocr2key/export_test.go @@ -18,6 +18,7 @@ func TestExport(t *testing.T) { {chain: chaintype.Cosmos}, {chain: chaintype.Solana}, {chain: chaintype.StarkNet}, + {chain: chaintype.Aptos}, } for _, tc := range tt { tc := tc diff --git a/core/services/keystore/keys/ocr2key/key_bundle.go b/core/services/keystore/keys/ocr2key/key_bundle.go index 2c3a4bebeb0..2c25a159fef 100644 --- a/core/services/keystore/keys/ocr2key/key_bundle.go +++ b/core/services/keystore/keys/ocr2key/key_bundle.go @@ -43,6 +43,7 @@ var _ KeyBundle = &keyBundle[*evmKeyring]{} var _ KeyBundle = &keyBundle[*cosmosKeyring]{} var _ KeyBundle = &keyBundle[*solanaKeyring]{} var _ KeyBundle = &keyBundle[*starkkey.OCR2Key]{} +var _ KeyBundle = &keyBundle[*aptosKeyring]{} var curve = secp256k1.S256() @@ -57,6 +58,8 @@ func New(chainType chaintype.ChainType) (KeyBundle, error) { return newKeyBundleRand(chaintype.Solana, newSolanaKeyring) case chaintype.StarkNet: return newKeyBundleRand(chaintype.StarkNet, starkkey.NewOCR2Key) + case chaintype.Aptos: + return newKeyBundleRand(chaintype.Aptos, newAptosKeyring) } return nil, chaintype.NewErrInvalidChainType(chainType) } @@ -72,6 +75,8 @@ func MustNewInsecure(reader io.Reader, chainType chaintype.ChainType) KeyBundle return mustNewKeyBundleInsecure(chaintype.Solana, newSolanaKeyring, reader) case chaintype.StarkNet: return mustNewKeyBundleInsecure(chaintype.StarkNet, starkkey.NewOCR2Key, reader) + case chaintype.Aptos: + return mustNewKeyBundleInsecure(chaintype.Aptos, newAptosKeyring, reader) } panic(chaintype.NewErrInvalidChainType(chainType)) } @@ -121,6 +126,8 @@ func (raw Raw) Key() (kb KeyBundle) { kb = newKeyBundle(new(solanaKeyring)) case chaintype.StarkNet: kb = newKeyBundle(new(starkkey.OCR2Key)) + case chaintype.Aptos: + kb = newKeyBundle(new(aptosKeyring)) default: return nil } diff --git a/core/services/keystore/keys/solkey/key.go b/core/services/keystore/keys/solkey/key.go index bcec5e89404..c6cdd62d3bf 100644 --- a/core/services/keystore/keys/solkey/key.go +++ b/core/services/keystore/keys/solkey/key.go @@ -11,7 +11,7 @@ import ( "github.com/mr-tron/base58" ) -// Raw represents the ETH private key +// Raw represents the Solana private key type Raw []byte // Key gets the Key diff --git a/core/services/keystore/keystoretest.go b/core/services/keystore/keystoretest.go index 91dc87ffe95..990f06c91ab 100644 --- a/core/services/keystore/keystoretest.go +++ b/core/services/keystore/keystoretest.go @@ -73,6 +73,7 @@ func NewInMemory(ds sqlutil.DataSource, scryptParams utils.ScryptParams, lggr lo p2p: newP2PKeyStore(km), solana: newSolanaKeyStore(km), starknet: newStarkNetKeyStore(km), + aptos: newAptosKeyStore(km), vrf: newVRFKeyStore(km), dkgSign: newDKGSignKeyStore(km), dkgEncrypt: newDKGEncryptKeyStore(km), diff --git a/core/services/keystore/master.go b/core/services/keystore/master.go index bb5b21a96f0..da42f5368ca 100644 --- a/core/services/keystore/master.go +++ b/core/services/keystore/master.go @@ -11,6 +11,7 @@ import ( "github.com/smartcontractkit/chainlink-common/pkg/sqlutil" "github.com/smartcontractkit/chainlink/v2/core/logger" + "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/aptoskey" "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/cosmoskey" "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/csakey" "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/dkgencryptkey" @@ -48,6 +49,7 @@ type Master interface { Solana() Solana Cosmos() Cosmos StarkNet() StarkNet + Aptos() Aptos VRF() VRF Unlock(ctx context.Context, password string) error IsEmpty(ctx context.Context) (bool, error) @@ -63,6 +65,7 @@ type master struct { p2p *p2p solana *solana starknet *starknet + aptos *aptos vrf *vrf dkgSign *dkgSign dkgEncrypt *dkgEncrypt @@ -92,6 +95,7 @@ func newMaster(ds sqlutil.DataSource, scryptParams utils.ScryptParams, lggr logg p2p: newP2PKeyStore(km), solana: newSolanaKeyStore(km), starknet: newStarkNetKeyStore(km), + aptos: newAptosKeyStore(km), vrf: newVRFKeyStore(km), dkgSign: newDKGSignKeyStore(km), dkgEncrypt: newDKGEncryptKeyStore(km), @@ -138,6 +142,10 @@ func (ks *master) StarkNet() StarkNet { return ks.starknet } +func (ks *master) Aptos() Aptos { + return ks.aptos +} + func (ks *master) VRF() VRF { return ks.vrf } @@ -273,6 +281,8 @@ func GetFieldNameForKey(unknownKey Key) (string, error) { return "Solana", nil case starkkey.Key: return "StarkNet", nil + case aptoskey.Key: + return "Aptos", nil case vrfkey.KeyV2: return "VRF", nil case dkgsignkey.Key: diff --git a/core/services/keystore/mocks/aptos.go b/core/services/keystore/mocks/aptos.go new file mode 100644 index 00000000000..476112149bf --- /dev/null +++ b/core/services/keystore/mocks/aptos.go @@ -0,0 +1,268 @@ +// Code generated by mockery v2.43.2. DO NOT EDIT. + +package mocks + +import ( + context "context" + + aptoskey "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/aptoskey" + + mock "github.com/stretchr/testify/mock" +) + +// Aptos is an autogenerated mock type for the Aptos type +type Aptos struct { + mock.Mock +} + +// Add provides a mock function with given fields: ctx, key +func (_m *Aptos) Add(ctx context.Context, key aptoskey.Key) error { + ret := _m.Called(ctx, key) + + if len(ret) == 0 { + panic("no return value specified for Add") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, aptoskey.Key) error); ok { + r0 = rf(ctx, key) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Create provides a mock function with given fields: ctx +func (_m *Aptos) Create(ctx context.Context) (aptoskey.Key, error) { + ret := _m.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for Create") + } + + var r0 aptoskey.Key + var r1 error + if rf, ok := ret.Get(0).(func(context.Context) (aptoskey.Key, error)); ok { + return rf(ctx) + } + if rf, ok := ret.Get(0).(func(context.Context) aptoskey.Key); ok { + r0 = rf(ctx) + } else { + r0 = ret.Get(0).(aptoskey.Key) + } + + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Delete provides a mock function with given fields: ctx, id +func (_m *Aptos) Delete(ctx context.Context, id string) (aptoskey.Key, error) { + ret := _m.Called(ctx, id) + + if len(ret) == 0 { + panic("no return value specified for Delete") + } + + var r0 aptoskey.Key + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) (aptoskey.Key, error)); ok { + return rf(ctx, id) + } + if rf, ok := ret.Get(0).(func(context.Context, string) aptoskey.Key); ok { + r0 = rf(ctx, id) + } else { + r0 = ret.Get(0).(aptoskey.Key) + } + + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// EnsureKey provides a mock function with given fields: ctx +func (_m *Aptos) EnsureKey(ctx context.Context) error { + ret := _m.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for EnsureKey") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context) error); ok { + r0 = rf(ctx) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Export provides a mock function with given fields: id, password +func (_m *Aptos) Export(id string, password string) ([]byte, error) { + ret := _m.Called(id, password) + + if len(ret) == 0 { + panic("no return value specified for Export") + } + + var r0 []byte + var r1 error + if rf, ok := ret.Get(0).(func(string, string) ([]byte, error)); ok { + return rf(id, password) + } + if rf, ok := ret.Get(0).(func(string, string) []byte); ok { + r0 = rf(id, password) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]byte) + } + } + + if rf, ok := ret.Get(1).(func(string, string) error); ok { + r1 = rf(id, password) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Get provides a mock function with given fields: id +func (_m *Aptos) Get(id string) (aptoskey.Key, error) { + ret := _m.Called(id) + + if len(ret) == 0 { + panic("no return value specified for Get") + } + + var r0 aptoskey.Key + var r1 error + if rf, ok := ret.Get(0).(func(string) (aptoskey.Key, error)); ok { + return rf(id) + } + if rf, ok := ret.Get(0).(func(string) aptoskey.Key); ok { + r0 = rf(id) + } else { + r0 = ret.Get(0).(aptoskey.Key) + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetAll provides a mock function with given fields: +func (_m *Aptos) GetAll() ([]aptoskey.Key, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetAll") + } + + var r0 []aptoskey.Key + var r1 error + if rf, ok := ret.Get(0).(func() ([]aptoskey.Key, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() []aptoskey.Key); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]aptoskey.Key) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Import provides a mock function with given fields: ctx, keyJSON, password +func (_m *Aptos) Import(ctx context.Context, keyJSON []byte, password string) (aptoskey.Key, error) { + ret := _m.Called(ctx, keyJSON, password) + + if len(ret) == 0 { + panic("no return value specified for Import") + } + + var r0 aptoskey.Key + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, []byte, string) (aptoskey.Key, error)); ok { + return rf(ctx, keyJSON, password) + } + if rf, ok := ret.Get(0).(func(context.Context, []byte, string) aptoskey.Key); ok { + r0 = rf(ctx, keyJSON, password) + } else { + r0 = ret.Get(0).(aptoskey.Key) + } + + if rf, ok := ret.Get(1).(func(context.Context, []byte, string) error); ok { + r1 = rf(ctx, keyJSON, password) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Sign provides a mock function with given fields: ctx, id, msg +func (_m *Aptos) Sign(ctx context.Context, id string, msg []byte) ([]byte, error) { + ret := _m.Called(ctx, id, msg) + + if len(ret) == 0 { + panic("no return value specified for Sign") + } + + var r0 []byte + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, []byte) ([]byte, error)); ok { + return rf(ctx, id, msg) + } + if rf, ok := ret.Get(0).(func(context.Context, string, []byte) []byte); ok { + r0 = rf(ctx, id, msg) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]byte) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, []byte) error); ok { + r1 = rf(ctx, id, msg) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// NewAptos creates a new instance of Aptos. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewAptos(t interface { + mock.TestingT + Cleanup(func()) +}) *Aptos { + mock := &Aptos{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/core/services/keystore/mocks/master.go b/core/services/keystore/mocks/master.go index 7c9b51c7231..0e706141704 100644 --- a/core/services/keystore/mocks/master.go +++ b/core/services/keystore/mocks/master.go @@ -14,6 +14,26 @@ type Master struct { mock.Mock } +// Aptos provides a mock function with given fields: +func (_m *Master) Aptos() keystore.Aptos { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Aptos") + } + + var r0 keystore.Aptos + if rf, ok := ret.Get(0).(func() keystore.Aptos); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(keystore.Aptos) + } + } + + return r0 +} + // CSA provides a mock function with given fields: func (_m *Master) CSA() keystore.CSA { ret := _m.Called() diff --git a/core/services/keystore/models.go b/core/services/keystore/models.go index bb84efdd024..3c4efdf2695 100644 --- a/core/services/keystore/models.go +++ b/core/services/keystore/models.go @@ -11,6 +11,7 @@ import ( "github.com/pkg/errors" "github.com/smartcontractkit/chainlink/v2/core/logger" + "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/aptoskey" "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/cosmoskey" "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/csakey" "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/dkgencryptkey" @@ -157,6 +158,7 @@ type keyRing struct { Cosmos map[string]cosmoskey.Key Solana map[string]solkey.Key StarkNet map[string]starkkey.Key + Aptos map[string]aptoskey.Key VRF map[string]vrfkey.KeyV2 DKGSign map[string]dkgsignkey.Key DKGEncrypt map[string]dkgencryptkey.Key @@ -173,6 +175,7 @@ func newKeyRing() *keyRing { Cosmos: make(map[string]cosmoskey.Key), Solana: make(map[string]solkey.Key), StarkNet: make(map[string]starkkey.Key), + Aptos: make(map[string]aptoskey.Key), VRF: make(map[string]vrfkey.KeyV2), DKGSign: make(map[string]dkgsignkey.Key), DKGEncrypt: make(map[string]dkgencryptkey.Key), @@ -233,6 +236,9 @@ func (kr *keyRing) raw() (rawKeys rawKeyRing) { for _, starkkey := range kr.StarkNet { rawKeys.StarkNet = append(rawKeys.StarkNet, starkkey.Raw()) } + for _, aptoskey := range kr.Aptos { + rawKeys.Aptos = append(rawKeys.Aptos, aptoskey.Raw()) + } for _, vrfKey := range kr.VRF { rawKeys.VRF = append(rawKeys.VRF, vrfKey.Raw()) } @@ -279,6 +285,10 @@ func (kr *keyRing) logPubKeys(lggr logger.Logger) { for _, starkkey := range kr.StarkNet { starknetIDs = append(starknetIDs, starkkey.ID()) } + var aptosIDs []string + for _, aptosKey := range kr.Aptos { + aptosIDs = append(aptosIDs, aptosKey.ID()) + } var vrfIDs []string for _, VRFKey := range kr.VRF { vrfIDs = append(vrfIDs, VRFKey.ID()) @@ -315,6 +325,9 @@ func (kr *keyRing) logPubKeys(lggr logger.Logger) { if len(starknetIDs) > 0 { lggr.Infow(fmt.Sprintf("Unlocked %d StarkNet keys", len(starknetIDs)), "keys", starknetIDs) } + if len(aptosIDs) > 0 { + lggr.Infow(fmt.Sprintf("Unlocked %d Aptos keys", len(aptosIDs)), "keys", aptosIDs) + } if len(vrfIDs) > 0 { lggr.Infow(fmt.Sprintf("Unlocked %d VRF keys", len(vrfIDs)), "keys", vrfIDs) } @@ -341,6 +354,7 @@ type rawKeyRing struct { Cosmos []cosmoskey.Raw Solana []solkey.Raw StarkNet []starkkey.Raw + Aptos []aptoskey.Raw VRF []vrfkey.Raw DKGSign []dkgsignkey.Raw DKGEncrypt []dkgencryptkey.Raw @@ -382,6 +396,10 @@ func (rawKeys rawKeyRing) keys() (*keyRing, error) { starkKey := rawStarkNetKey.Key() keyRing.StarkNet[starkKey.ID()] = starkKey } + for _, rawAptosKey := range rawKeys.Aptos { + aptosKey := rawAptosKey.Key() + keyRing.Aptos[aptosKey.ID()] = aptosKey + } for _, rawVRFKey := range rawKeys.VRF { vrfKey := rawVRFKey.Key() keyRing.VRF[vrfKey.ID()] = vrfKey diff --git a/core/services/keystore/solana.go b/core/services/keystore/solana.go index e95af37a7fa..ba4031b64cc 100644 --- a/core/services/keystore/solana.go +++ b/core/services/keystore/solana.go @@ -6,6 +6,7 @@ import ( "github.com/pkg/errors" + "github.com/smartcontractkit/chainlink-common/pkg/loop" "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/solkey" ) @@ -28,6 +29,8 @@ type SolanaSigner struct { Solana } +var _ loop.Keystore = &SolanaSigner{} + func (s *SolanaSigner) Accounts(ctx context.Context) (accounts []string, err error) { ks, err := s.GetAll() if err != nil { diff --git a/core/web/aptos_keys_controller.go b/core/web/aptos_keys_controller.go new file mode 100644 index 00000000000..5be4b815773 --- /dev/null +++ b/core/web/aptos_keys_controller.go @@ -0,0 +1,12 @@ +package web + +import ( + "github.com/smartcontractkit/chainlink/v2/core/services/chainlink" + "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/aptoskey" + "github.com/smartcontractkit/chainlink/v2/core/web/presenters" +) + +func NewAptosKeysController(app chainlink.Application) KeysController { + return NewKeysController[aptoskey.Key, presenters.AptosKeyResource](app.GetKeyStore().Aptos(), app.GetLogger(), app.GetAuditLogger(), + "aptosKey", presenters.NewAptosKeyResource, presenters.NewAptosKeyResources) +} diff --git a/core/web/aptos_keys_controller_test.go b/core/web/aptos_keys_controller_test.go new file mode 100644 index 00000000000..c5de75a5105 --- /dev/null +++ b/core/web/aptos_keys_controller_test.go @@ -0,0 +1,109 @@ +package web_test + +import ( + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-common/pkg/utils" + "github.com/smartcontractkit/chainlink/v2/core/internal/cltest" + "github.com/smartcontractkit/chainlink/v2/core/internal/testutils" + "github.com/smartcontractkit/chainlink/v2/core/services/keystore" + "github.com/smartcontractkit/chainlink/v2/core/web" + "github.com/smartcontractkit/chainlink/v2/core/web/presenters" +) + +func TestAptosKeysController_Index_HappyPath(t *testing.T) { + t.Parallel() + + client, keyStore := setupAptosKeysControllerTests(t) + keys, _ := keyStore.Aptos().GetAll() + + response, cleanup := client.Get("/v2/keys/aptos") + t.Cleanup(cleanup) + cltest.AssertServerResponse(t, response, http.StatusOK) + + resources := []presenters.AptosKeyResource{} + err := web.ParseJSONAPIResponse(cltest.ParseResponseBody(t, response), &resources) + assert.NoError(t, err) + + require.Len(t, resources, len(keys)) + + assert.Equal(t, keys[0].ID(), resources[0].ID) + assert.Equal(t, keys[0].PublicKeyStr(), resources[0].PubKey) +} + +func TestAptosKeysController_Create_HappyPath(t *testing.T) { + t.Parallel() + + app := cltest.NewApplicationEVMDisabled(t) + require.NoError(t, app.Start(testutils.Context(t))) + client := app.NewHTTPClient(nil) + keyStore := app.GetKeyStore() + + response, cleanup := client.Post("/v2/keys/aptos", nil) + t.Cleanup(cleanup) + cltest.AssertServerResponse(t, response, http.StatusOK) + + keys, _ := keyStore.Aptos().GetAll() + require.Len(t, keys, 1) + + resource := presenters.AptosKeyResource{} + err := web.ParseJSONAPIResponse(cltest.ParseResponseBody(t, response), &resource) + assert.NoError(t, err) + + assert.Equal(t, keys[0].ID(), resource.ID) + assert.Equal(t, keys[0].PublicKeyStr(), resource.PubKey) + + _, err = keyStore.Aptos().Get(resource.ID) + require.NoError(t, err) +} + +func TestAptosKeysController_Delete_NonExistentAptosKeyID(t *testing.T) { + t.Parallel() + + client, _ := setupAptosKeysControllerTests(t) + + nonExistentAptosKeyID := "foobar" + response, cleanup := client.Delete("/v2/keys/aptos/" + nonExistentAptosKeyID) + t.Cleanup(cleanup) + assert.Equal(t, http.StatusNotFound, response.StatusCode) +} + +func TestAptosKeysController_Delete_HappyPath(t *testing.T) { + t.Parallel() + ctx := testutils.Context(t) + + client, keyStore := setupAptosKeysControllerTests(t) + + keys, _ := keyStore.Aptos().GetAll() + initialLength := len(keys) + key, _ := keyStore.Aptos().Create(ctx) + + response, cleanup := client.Delete(fmt.Sprintf("/v2/keys/aptos/%s", key.ID())) + t.Cleanup(cleanup) + assert.Equal(t, http.StatusOK, response.StatusCode) + assert.Error(t, utils.JustError(keyStore.Aptos().Get(key.ID()))) + + keys, _ = keyStore.Aptos().GetAll() + assert.Equal(t, initialLength, len(keys)) +} + +func setupAptosKeysControllerTests(t *testing.T) (cltest.HTTPClientCleaner, keystore.Master) { + t.Helper() + ctx := testutils.Context(t) + + app := cltest.NewApplication(t) + require.NoError(t, app.Start(ctx)) + require.NoError(t, app.KeyStore.OCR().Add(ctx, cltest.DefaultOCRKey)) + aptosKeyStore := app.GetKeyStore().Aptos() + require.NotNil(t, aptosKeyStore) + require.NoError(t, aptosKeyStore.Add(ctx, cltest.DefaultAptosKey)) + + client := app.NewHTTPClient(nil) + + return client, app.GetKeyStore() +} diff --git a/core/web/auth/auth_test.go b/core/web/auth/auth_test.go index 9896a2e359a..632527ac762 100644 --- a/core/web/auth/auth_test.go +++ b/core/web/auth/auth_test.go @@ -274,18 +274,28 @@ var routesRolesMap = [...]routeRules{ {"POST", "/v2/keys/p2p/export/MOCK", false, false, false}, {"GET", "/v2/keys/solana", true, true, true}, {"GET", "/v2/keys/cosmos", true, true, true}, + {"GET", "/v2/keys/starknet", true, true, true}, + {"GET", "/v2/keys/aptos", true, true, true}, {"GET", "/v2/keys/dkgsign", true, true, true}, {"POST", "/v2/keys/solana", false, false, true}, {"POST", "/v2/keys/cosmos", false, false, true}, + {"POST", "/v2/keys/starknet", false, false, true}, + {"POST", "/v2/keys/aptos", false, false, true}, {"POST", "/v2/keys/dkgsign", false, false, true}, {"DELETE", "/v2/keys/solana/MOCK", false, false, false}, {"DELETE", "/v2/keys/cosmos/MOCK", false, false, false}, + {"DELETE", "/v2/keys/starknet/MOCK", false, false, false}, + {"DELETE", "/v2/keys/aptos/MOCK", false, false, false}, {"DELETE", "/v2/keys/dkgsign/MOCK", false, false, false}, {"POST", "/v2/keys/solana/import", false, false, false}, {"POST", "/v2/keys/cosmos/import", false, false, false}, + {"POST", "/v2/keys/starknet/import", false, false, false}, + {"POST", "/v2/keys/aptos/import", false, false, false}, {"POST", "/v2/keys/dkgsign/import", false, false, false}, {"POST", "/v2/keys/solana/export/MOCK", false, false, false}, {"POST", "/v2/keys/cosmos/export/MOCK", false, false, false}, + {"POST", "/v2/keys/starknet/export/MOCK", false, false, false}, + {"POST", "/v2/keys/aptos/export/MOCK", false, false, false}, {"POST", "/v2/keys/dkgsign/export/MOCK", false, false, false}, {"GET", "/v2/keys/vrf", true, true, true}, {"POST", "/v2/keys/vrf", false, false, true}, diff --git a/core/web/presenters/aptos_key.go b/core/web/presenters/aptos_key.go new file mode 100644 index 00000000000..6460c325f97 --- /dev/null +++ b/core/web/presenters/aptos_key.go @@ -0,0 +1,32 @@ +package presenters + +import "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/aptoskey" + +// AptosKeyResource represents a Aptos key JSONAPI resource. +type AptosKeyResource struct { + JAID + PubKey string `json:"publicKey"` +} + +// GetName implements the api2go EntityNamer interface +func (AptosKeyResource) GetName() string { + return "encryptedAptosKeys" +} + +func NewAptosKeyResource(key aptoskey.Key) *AptosKeyResource { + r := &AptosKeyResource{ + JAID: JAID{ID: key.ID()}, + PubKey: key.PublicKeyStr(), + } + + return r +} + +func NewAptosKeyResources(keys []aptoskey.Key) []AptosKeyResource { + rs := []AptosKeyResource{} + for _, key := range keys { + rs = append(rs, *NewAptosKeyResource(key)) + } + + return rs +} diff --git a/core/web/resolver/ocr2_keys.go b/core/web/resolver/ocr2_keys.go index df184109331..d04ebbd0e2f 100644 --- a/core/web/resolver/ocr2_keys.go +++ b/core/web/resolver/ocr2_keys.go @@ -25,6 +25,8 @@ const ( OCR2ChainTypeSolana = "SOLANA" // OCR2ChainTypeStarkNet defines OCR2 StarkNet Chain Type OCR2ChainTypeStarkNet = "STARKNET" + // OCRChainTypeAptos defines OCR Aptos Chain Type + OCRChainTypeAptos = "APTOS" ) // ToOCR2ChainType turns a valid string into a OCR2ChainType @@ -38,6 +40,8 @@ func ToOCR2ChainType(s string) (OCR2ChainType, error) { return OCR2ChainTypeSolana, nil case string(chaintype.StarkNet): return OCR2ChainTypeStarkNet, nil + case string(chaintype.Aptos): + return OCRChainTypeAptos, nil default: return "", errors.New("unknown ocr2 chain type") } @@ -54,6 +58,8 @@ func FromOCR2ChainType(ct OCR2ChainType) string { return string(chaintype.Solana) case OCR2ChainTypeStarkNet: return string(chaintype.StarkNet) + case OCRChainTypeAptos: + return string(chaintype.Aptos) default: return strings.ToLower(string(ct)) } diff --git a/core/web/resolver/ocr2_keys_test.go b/core/web/resolver/ocr2_keys_test.go index 2269149bc37..033d22799b1 100644 --- a/core/web/resolver/ocr2_keys_test.go +++ b/core/web/resolver/ocr2_keys_test.go @@ -41,6 +41,7 @@ func TestResolver_GetOCR2KeyBundles(t *testing.T) { ocr2key.MustNewInsecure(keystest.NewRandReaderFromSeed(1), "cosmos"), ocr2key.MustNewInsecure(keystest.NewRandReaderFromSeed(1), "solana"), ocr2key.MustNewInsecure(keystest.NewRandReaderFromSeed(1), "starknet"), + ocr2key.MustNewInsecure(keystest.NewRandReaderFromSeed(1), "aptos"), } expectedBundles := []map[string]interface{}{} for _, k := range fakeKeys { diff --git a/core/web/router.go b/core/web/router.go index 5e90cea237b..a220df220c7 100644 --- a/core/web/router.go +++ b/core/web/router.go @@ -350,6 +350,7 @@ func v2Routes(app chainlink.Application, r *gin.RouterGroup) { {"solana", NewSolanaKeysController(app)}, {"cosmos", NewCosmosKeysController(app)}, {"starknet", NewStarkNetKeysController(app)}, + {"aptos", NewAptosKeysController(app)}, {"dkgsign", NewDKGSignKeysController(app)}, {"dkgencrypt", NewDKGEncryptKeysController(app)}, } { diff --git a/core/web/schema/type/ocr2_keys.graphql b/core/web/schema/type/ocr2_keys.graphql index 95da9acf71f..c25148c686a 100644 --- a/core/web/schema/type/ocr2_keys.graphql +++ b/core/web/schema/type/ocr2_keys.graphql @@ -3,6 +3,7 @@ enum OCR2ChainType { COSMOS SOLANA STARKNET + APTOS } type OCR2KeyBundle { diff --git a/testdata/scripts/help-all/help-all.txtar b/testdata/scripts/help-all/help-all.txtar index e111295abb4..93b24580e36 100644 --- a/testdata/scripts/help-all/help-all.txtar +++ b/testdata/scripts/help-all/help-all.txtar @@ -55,6 +55,12 @@ jobs list # List all jobs jobs run # Trigger a job run jobs show # Show a job keys # Commands for managing various types of keys used by the Chainlink node +keys aptos # Remote commands for administering the node's Aptos keys +keys aptos create # Create a Aptos key +keys aptos delete # Delete Aptos key if present +keys aptos export # Export Aptos key to keyfile +keys aptos import # Import Aptos key from keyfile +keys aptos list # List the Aptos keys keys cosmos # Remote commands for administering the node's Cosmos keys keys cosmos create # Create a Cosmos key keys cosmos delete # Delete Cosmos key if present diff --git a/testdata/scripts/keys/aptos/help.txtar b/testdata/scripts/keys/aptos/help.txtar new file mode 100644 index 00000000000..94f0c7db8c3 --- /dev/null +++ b/testdata/scripts/keys/aptos/help.txtar @@ -0,0 +1,20 @@ +exec chainlink keys aptos --help +cmp stdout out.txt + +-- out.txt -- +NAME: + chainlink keys aptos - Remote commands for administering the node's Aptos keys + +USAGE: + chainlink keys aptos command [command options] [arguments...] + +COMMANDS: + create Create a Aptos key + import Import Aptos key from keyfile + export Export Aptos key to keyfile + delete Delete Aptos key if present + list List the Aptos keys + +OPTIONS: + --help, -h show help + diff --git a/testdata/scripts/keys/help.txtar b/testdata/scripts/keys/help.txtar index a54002e272b..5c144017c66 100644 --- a/testdata/scripts/keys/help.txtar +++ b/testdata/scripts/keys/help.txtar @@ -17,6 +17,7 @@ COMMANDS: cosmos Remote commands for administering the node's Cosmos keys solana Remote commands for administering the node's Solana keys starknet Remote commands for administering the node's StarkNet keys + aptos Remote commands for administering the node's Aptos keys dkgsign Remote commands for administering the node's DKGSign keys dkgencrypt Remote commands for administering the node's DKGEncrypt keys vrf Remote commands for administering the node's vrf keys