diff --git a/.changeset/wise-buttons-fry.md b/.changeset/wise-buttons-fry.md new file mode 100644 index 00000000000..aa3cb1dab79 --- /dev/null +++ b/.changeset/wise-buttons-fry.md @@ -0,0 +1,5 @@ +--- +"chainlink": patch +--- + +Add TRON integration #added diff --git a/.mockery.yaml b/.mockery.yaml index 5777ca1da92..b9e2515b460 100644 --- a/.mockery.yaml +++ b/.mockery.yaml @@ -233,6 +233,7 @@ packages: StarkNet: config: filename: starknet.go + Tron: VRF: Workflow: github.com/smartcontractkit/chainlink/v2/core/services/ocr: @@ -591,4 +592,4 @@ packages: dir: "{{ .InterfaceDir }}" github.com/smartcontractkit/chainlink/v2/core/capabilities/targets: interfaces: - ContractValueGetter: \ No newline at end of file + ContractValueGetter: diff --git a/core/cmd/app.go b/core/cmd/app.go index 8128d578238..f605b3973c7 100644 --- a/core/cmd/app.go +++ b/core/cmd/app.go @@ -201,6 +201,7 @@ func NewApp(s *Shell) *cli.App { keysCommand("Solana", NewSolanaKeysClient(s)), keysCommand("StarkNet", NewStarkNetKeysClient(s)), keysCommand("Aptos", NewAptosKeysClient(s)), + keysCommand("Tron", NewTronKeysClient(s)), initVRFKeysSubCmd(s), }, diff --git a/core/cmd/shell.go b/core/cmd/shell.go index 94664a3cf3d..09eacd7dc39 100644 --- a/core/cmd/shell.go +++ b/core/cmd/shell.go @@ -293,6 +293,13 @@ func (n ChainlinkAppFactory) NewApplication(ctx context.Context, cfg chainlink.G } initOps = append(initOps, chainlink.InitAptos(ctx, relayerFactory, aptosCfg)) } + if cfg.TronEnabled() { + tronCfg := chainlink.TronFactoryConfig{ + Keystore: keyStore.Tron(), + TOMLConfigs: cfg.TronConfigs(), + } + initOps = append(initOps, chainlink.InitTron(ctx, relayerFactory, tronCfg)) + } relayChainInterops, err := chainlink.NewCoreRelayerChainInteroperators(initOps...) if err != nil { diff --git a/core/cmd/shell_local.go b/core/cmd/shell_local.go index 1fdc1a46d34..ed71c5be369 100644 --- a/core/cmd/shell_local.go +++ b/core/cmd/shell_local.go @@ -469,6 +469,12 @@ func (s *Shell) runNode(c *cli.Context) error { return errors.Wrap(err2, "failed to ensure aptos key") } } + if s.Config.TronEnabled() { + err2 := app.GetKeyStore().Tron().EnsureKey(rootCtx) + if err2 != nil { + return errors.Wrap(err2, "failed to ensure tron key") + } + } err2 := app.GetKeyStore().Workflow().EnsureKey(rootCtx) if err2 != nil { diff --git a/core/cmd/tron_keys_commands.go b/core/cmd/tron_keys_commands.go new file mode 100644 index 00000000000..67b3242e1f5 --- /dev/null +++ b/core/cmd/tron_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/tronkey" + "github.com/smartcontractkit/chainlink/v2/core/web/presenters" +) + +type TronKeyPresenter struct { + JAID + presenters.TronKeyResource +} + +// RenderTable implements TableRenderer +func (p TronKeyPresenter) RenderTable(rt RendererTable) error { + headers := []string{"ID", "Public key"} + rows := [][]string{p.ToRow()} + + if _, err := rt.Write([]byte("🔑 Tron Keys\n")); err != nil { + return err + } + renderList(headers, rows, rt.Writer) + + return utils.JustError(rt.Write([]byte("\n"))) +} + +func (p *TronKeyPresenter) ToRow() []string { + row := []string{ + p.ID, + p.PubKey, + } + + return row +} + +type TronKeyPresenters []TronKeyPresenter + +// RenderTable implements TableRenderer +func (ps TronKeyPresenters) RenderTable(rt RendererTable) error { + headers := []string{"ID", "Public key"} + rows := [][]string{} + + for _, p := range ps { + rows = append(rows, p.ToRow()) + } + + if _, err := rt.Write([]byte("🔑 Tron Keys\n")); err != nil { + return err + } + renderList(headers, rows, rt.Writer) + + return utils.JustError(rt.Write([]byte("\n"))) +} + +func NewTronKeysClient(s *Shell) KeysClient { + return newKeysClient[tronkey.Key, TronKeyPresenter, TronKeyPresenters]("Tron", s) +} diff --git a/core/cmd/tron_keys_commands_test.go b/core/cmd/tron_keys_commands_test.go new file mode 100644 index 00000000000..29480600d74 --- /dev/null +++ b/core/cmd/tron_keys_commands_test.go @@ -0,0 +1,174 @@ +package cmd_test + +import ( + "bytes" + "context" + "flag" + "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/tronkey" + "github.com/smartcontractkit/chainlink/v2/core/web/presenters" +) + +func TestTronKeyPresenter_RenderTable(t *testing.T) { + t.Parallel() + + var ( + id = "1" + pubKey = "somepubkey" + buffer = bytes.NewBufferString("") + r = cmd.RendererTable{Writer: buffer} + ) + + p := cmd.TronKeyPresenter{ + JAID: cmd.JAID{ID: id}, + TronKeyResource: presenters.TronKeyResource{ + 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.TronKeyPresenters{p} + require.NoError(t, ps.RenderTable(r)) + + output = buffer.String() + assert.Contains(t, output, id) + assert.Contains(t, output, pubKey) +} + +func TestShell_TronKeys(t *testing.T) { + app := startNewApplicationV2(t, nil) + ks := app.GetKeyStore().Tron() + 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()))) + } + requireTronKeyCount(t, app, 0) + } + + t.Run("ListTronKeys", func(tt *testing.T) { + defer cleanup() + ctx := testutils.Context(t) + client, r := app.NewShellAndRenderer() + key, err := app.GetKeyStore().Tron().Create(ctx) + require.NoError(t, err) + requireTronKeyCount(t, app, 1) + require.NoError(t, cmd.NewTronKeysClient(client).ListKeys(cltest.EmptyCLIContext())) + require.Len(t, r.Renders, 1) + keys := *r.Renders[0].(*cmd.TronKeyPresenters) + assert.Equal(t, key.PublicKeyStr(), keys[0].PubKey) + }) + + t.Run("CreateTronKey", func(tt *testing.T) { + defer cleanup() + client, _ := app.NewShellAndRenderer() + require.NoError(t, cmd.NewTronKeysClient(client).CreateKey(nilContext)) + keys, err := app.GetKeyStore().Tron().GetAll() + require.NoError(t, err) + require.Len(t, keys, 1) + }) + + t.Run("DeleteTronKey", func(tt *testing.T) { + defer cleanup() + ctx := testutils.Context(t) + client, _ := app.NewShellAndRenderer() + key, err := app.GetKeyStore().Tron().Create(ctx) + require.NoError(t, err) + requireTronKeyCount(t, app, 1) + set := flag.NewFlagSet("test", 0) + flagSetApplyFromAction(cmd.NewTronKeysClient(client).DeleteKey, set, "tron") + + 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.NewTronKeysClient(client).DeleteKey(c) + require.NoError(t, err) + requireTronKeyCount(t, app, 0) + }) + + t.Run("ImportExportTronKey", func(tt *testing.T) { + defer cleanup() + defer deleteKeyExportFile(t) + ctx := testutils.Context(t) + client, _ := app.NewShellAndRenderer() + + _, err := app.GetKeyStore().Tron().Create(ctx) + require.NoError(t, err) + + keys := requireTronKeyCount(t, app, 1) + key := keys[0] + keyName := keyNameForTest(t) + + // Export test invalid id + set := flag.NewFlagSet("test Tron export", 0) + flagSetApplyFromAction(cmd.NewTronKeysClient(client).ExportKey, set, "tron") + + 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.NewTronKeysClient(client).ExportKey(c) + require.Error(t, err, "Error exporting") + require.Error(t, utils.JustError(os.Stat(keyName))) + + // Export test + set = flag.NewFlagSet("test Tron export", 0) + flagSetApplyFromAction(cmd.NewTronKeysClient(client).ExportKey, set, "tron") + + require.NoError(tt, set.Parse([]string{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.NewTronKeysClient(client).ExportKey(c)) + require.NoError(t, utils.JustError(os.Stat(keyName))) + + require.NoError(t, utils.JustError(app.GetKeyStore().Tron().Delete(ctx, key.ID()))) + requireTronKeyCount(t, app, 0) + + set = flag.NewFlagSet("test Tron import", 0) + flagSetApplyFromAction(cmd.NewTronKeysClient(client).ImportKey, set, "tron") + + 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.NewTronKeysClient(client).ImportKey(c)) + + requireTronKeyCount(t, app, 1) + }) +} + +func requireTronKeyCount(t *testing.T, app chainlink.Application, length int) []tronkey.Key { + t.Helper() + keys, err := app.GetKeyStore().Tron().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 3f2a5472b24..4ce8873bb96 100644 --- a/core/config/app_config.go +++ b/core/config/app_config.go @@ -25,6 +25,7 @@ type AppConfig interface { SolanaEnabled() bool StarkNetEnabled() bool AptosEnabled() bool + TronEnabled() bool Validate() error ValidateDB() error diff --git a/core/config/docs/chains-tron.toml b/core/config/docs/chains-tron.toml new file mode 100644 index 00000000000..55a44bacd7a --- /dev/null +++ b/core/config/docs/chains-tron.toml @@ -0,0 +1,13 @@ +[[Tron]] +# ChainID is the Tron chain ID. +ChainID = 'foobar' # Example +# Enabled enables this chain. +Enabled = true # Default + +[[Tron.Nodes]] +# Name is a unique (per-chain) identifier for this node. +Name = 'primary' # Example +# URL is the full node HTTP endpoint for this node. +URL = 'https://api.trongrid.io/wallet' # Example +# SolidityURL is the solidity node HTTP endpoint for this node. +SolidityURL = 'http://api.trongrid.io/wallet' # Example diff --git a/core/config/env/env.go b/core/config/env/env.go index c34cd7f4f5e..68b79c7575c 100644 --- a/core/config/env/env.go +++ b/core/config/env/env.go @@ -29,6 +29,7 @@ var ( MercuryPlugin = NewPlugin("mercury") SolanaPlugin = NewPlugin("solana") StarknetPlugin = NewPlugin("starknet") + TronPlugin = NewPlugin("tron") // PrometheusDiscoveryHostName is the externally accessible hostname // published by the node in the `/discovery` endpoint. Generally, it is expected to match // the public hostname of node. diff --git a/core/internal/cltest/cltest.go b/core/internal/cltest/cltest.go index a55c57cc9a2..7b8fe0e4281 100644 --- a/core/internal/cltest/cltest.go +++ b/core/internal/cltest/cltest.go @@ -82,6 +82,7 @@ import ( "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/p2pkey" "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/solkey" "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/starkkey" + "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/tronkey" "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/vrfkey" "github.com/smartcontractkit/chainlink/v2/core/services/pg" "github.com/smartcontractkit/chainlink/v2/core/services/pipeline" @@ -133,6 +134,7 @@ var ( DefaultSolanaKey = solkey.MustNewInsecure(keystest.NewRandReaderFromSeed(KeyBigIntSeed)) DefaultStarkNetKey = starkkey.MustNewInsecure(keystest.NewRandReaderFromSeed(KeyBigIntSeed)) DefaultAptosKey = aptoskey.MustNewInsecure(keystest.NewRandReaderFromSeed(KeyBigIntSeed)) + DefaultTronKey = tronkey.MustNewInsecure(keystest.NewRandReaderFromSeed(KeyBigIntSeed)) DefaultVRFKey = vrfkey.MustNewV2XXXTestingOnly(big.NewInt(KeyBigIntSeed)) ) @@ -471,6 +473,13 @@ func NewApplicationWithConfig(t testing.TB, cfg chainlink.GeneralConfig, flagsAn } initOps = append(initOps, chainlink.InitAptos(ctx, relayerFactory, aptosCfg)) } + if cfg.TronEnabled() { + tronCfg := chainlink.TronFactoryConfig{ + Keystore: keyStore.Tron(), + TOMLConfigs: cfg.TronConfigs(), + } + initOps = append(initOps, chainlink.InitTron(ctx, relayerFactory, tronCfg)) + } relayChainInterops, err := chainlink.NewCoreRelayerChainInteroperators(initOps...) if err != nil { diff --git a/core/services/chainlink/config.go b/core/services/chainlink/config.go index 9f083ef89af..dd54856cf0a 100644 --- a/core/services/chainlink/config.go +++ b/core/services/chainlink/config.go @@ -46,6 +46,8 @@ type Config struct { Starknet stkcfg.TOMLConfigs `toml:",omitempty"` Aptos RawConfigs `toml:",omitempty"` + + Tron RawConfigs `toml:",omitempty"` } // RawConfigs is a list of RawConfig. @@ -260,6 +262,7 @@ func (c *Config) TOMLString() (string, error) { // warnings aggregates warnings from valueWarnings and deprecationWarnings func (c *Config) warnings() (err error) { deprecationErr := c.deprecationWarnings() + warningErr := c.valueWarnings() err = multierr.Append(deprecationErr, warningErr) _, list := commonconfig.MultiErrorList(err) @@ -352,6 +355,10 @@ func (c *Config) SetFrom(f *Config) (err error) { err = multierr.Append(err, commonconfig.NamedMultiErrorList(err5, "Aptos")) } + if err6 := c.Tron.SetFrom(f.Tron); err6 != nil { + err = multierr.Append(err, commonconfig.NamedMultiErrorList(err6, "Tron")) + } + _, err = commonconfig.MultiErrorList(err) return err diff --git a/core/services/chainlink/config_general.go b/core/services/chainlink/config_general.go index dd0dc87b59a..e67d92fefc9 100644 --- a/core/services/chainlink/config_general.go +++ b/core/services/chainlink/config_general.go @@ -213,6 +213,10 @@ func (g *generalConfig) AptosConfigs() RawConfigs { return g.c.Aptos } +func (g *generalConfig) TronConfigs() RawConfigs { + return g.c.Tron +} + func (g *generalConfig) Validate() error { return g.validate(g.secrets.Validate) } @@ -358,6 +362,15 @@ func (g *generalConfig) AptosEnabled() bool { return false } +func (g *generalConfig) TronEnabled() bool { + for _, c := range g.c.Tron { + 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_general_test.go b/core/services/chainlink/config_general_test.go index 29393ee0fdd..3f02b880baf 100644 --- a/core/services/chainlink/config_general_test.go +++ b/core/services/chainlink/config_general_test.go @@ -28,6 +28,7 @@ func TestTOMLGeneralConfig_Defaults(t *testing.T) { assert.False(t, config.CosmosEnabled()) assert.False(t, config.SolanaEnabled()) assert.False(t, config.StarkNetEnabled()) + assert.False(t, config.TronEnabled()) assert.Equal(t, false, config.JobPipeline().ExternalInitiatorsEnabled()) assert.Equal(t, 15*time.Minute, config.WebServer().SessionTimeout().Duration()) } diff --git a/core/services/chainlink/config_test.go b/core/services/chainlink/config_test.go index 769005feb72..49c36d38724 100644 --- a/core/services/chainlink/config_test.go +++ b/core/services/chainlink/config_test.go @@ -1436,7 +1436,7 @@ func TestConfig_Validate(t *testing.T) { toml string exp string }{ - {name: "invalid", toml: invalidTOML, exp: `invalid configuration: 8 errors: + {name: "invalid", toml: invalidTOML, exp: `invalid configuration: 9 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: @@ -1533,6 +1533,11 @@ func TestConfig_Validate(t *testing.T) { - Nodes: missing: must have at least one node - Aptos: 2 errors: - 0.Nodes.1.Name: invalid value (primary): duplicate - must be unique + - 0: 2 errors: + - Enabled: invalid value (1): expected bool + - ChainID: missing: required for all chains + - Tron: 2 errors: + - 0.Nodes.1.Name: invalid value (tron-test): duplicate - must be unique - 0: 2 errors: - Enabled: invalid value (1): expected bool - ChainID: missing: required for all chains`}, diff --git a/core/services/chainlink/mocks/general_config.go b/core/services/chainlink/mocks/general_config.go index db1efb3d86f..2182c54c954 100644 --- a/core/services/chainlink/mocks/general_config.go +++ b/core/services/chainlink/mocks/general_config.go @@ -1959,6 +1959,98 @@ func (_c *GeneralConfig_Tracing_Call) RunAndReturn(run func() config.Tracing) *G return _c } +// TronConfigs provides a mock function with given fields: +func (_m *GeneralConfig) TronConfigs() chainlink.RawConfigs { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for TronConfigs") + } + + var r0 chainlink.RawConfigs + if rf, ok := ret.Get(0).(func() chainlink.RawConfigs); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(chainlink.RawConfigs) + } + } + + return r0 +} + +// GeneralConfig_TronConfigs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'TronConfigs' +type GeneralConfig_TronConfigs_Call struct { + *mock.Call +} + +// TronConfigs is a helper method to define mock.On call +func (_e *GeneralConfig_Expecter) TronConfigs() *GeneralConfig_TronConfigs_Call { + return &GeneralConfig_TronConfigs_Call{Call: _e.mock.On("TronConfigs")} +} + +func (_c *GeneralConfig_TronConfigs_Call) Run(run func()) *GeneralConfig_TronConfigs_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *GeneralConfig_TronConfigs_Call) Return(_a0 chainlink.RawConfigs) *GeneralConfig_TronConfigs_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *GeneralConfig_TronConfigs_Call) RunAndReturn(run func() chainlink.RawConfigs) *GeneralConfig_TronConfigs_Call { + _c.Call.Return(run) + return _c +} + +// TronEnabled provides a mock function with given fields: +func (_m *GeneralConfig) TronEnabled() bool { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for TronEnabled") + } + + var r0 bool + if rf, ok := ret.Get(0).(func() bool); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} + +// GeneralConfig_TronEnabled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'TronEnabled' +type GeneralConfig_TronEnabled_Call struct { + *mock.Call +} + +// TronEnabled is a helper method to define mock.On call +func (_e *GeneralConfig_Expecter) TronEnabled() *GeneralConfig_TronEnabled_Call { + return &GeneralConfig_TronEnabled_Call{Call: _e.mock.On("TronEnabled")} +} + +func (_c *GeneralConfig_TronEnabled_Call) Run(run func()) *GeneralConfig_TronEnabled_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *GeneralConfig_TronEnabled_Call) Return(_a0 bool) *GeneralConfig_TronEnabled_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *GeneralConfig_TronEnabled_Call) RunAndReturn(run func() bool) *GeneralConfig_TronEnabled_Call { + _c.Call.Return(run) + return _c +} + // Validate provides a mock function with given fields: func (_m *GeneralConfig) Validate() error { ret := _m.Called() diff --git a/core/services/chainlink/relayer_chain_interoperators.go b/core/services/chainlink/relayer_chain_interoperators.go index 2fc671bfe6e..6242af51935 100644 --- a/core/services/chainlink/relayer_chain_interoperators.go +++ b/core/services/chainlink/relayer_chain_interoperators.go @@ -204,6 +204,23 @@ func InitAptos(ctx context.Context, factory RelayerFactory, config AptosFactoryC } } +// InitTron is a option for instantiating Tron relayers +func InitTron(ctx context.Context, factory RelayerFactory, config TronFactoryConfig) CoreRelayerChainInitFunc { + return func(op *CoreRelayerChainInteroperators) error { + tronRelayers, err := factory.NewTron(config.Keystore, config.TOMLConfigs) + if err != nil { + return fmt.Errorf("failed to setup Tron relayer: %w", err) + } + + for id, relayer := range tronRelayers { + op.srvs = append(op.srvs, relayer) + op.loopRelayers[id] = relayer + } + + return nil + } +} + // Get a [loop.Relayer] by id func (rs *CoreRelayerChainInteroperators) Get(id types.RelayID) (loop.Relayer, error) { rs.mu.Lock() diff --git a/core/services/chainlink/relayer_chain_interoperators_test.go b/core/services/chainlink/relayer_chain_interoperators_test.go index a4bd8c168ba..f03e172542c 100644 --- a/core/services/chainlink/relayer_chain_interoperators_test.go +++ b/core/services/chainlink/relayer_chain_interoperators_test.go @@ -378,6 +378,8 @@ func TestCoreRelayerChainInteroperators(t *testing.T) { expectedChainCnt, expectedNodeCnt = tt.expectedDummyChainCnt, tt.expectedDummyNodeCnt case relay.NetworkAptos: t.Skip("aptos doesn't need a CoreRelayerChainInteroperator") + case relay.NetworkTron: + t.Skip("tron doesn't need a CoreRelayerChainInteroperator") default: require.Fail(t, "untested relay network", relayNetwork) diff --git a/core/services/chainlink/relayer_factory.go b/core/services/chainlink/relayer_factory.go index a1571663d5a..c173c7fecb7 100644 --- a/core/services/chainlink/relayer_factory.go +++ b/core/services/chainlink/relayer_factory.go @@ -371,3 +371,14 @@ func (r *RelayerFactory) NewLOOPRelayer(name string, network string, plugin env. } return relayers, nil } + +type TronFactoryConfig struct { + Keystore keystore.Tron + TOMLConfigs RawConfigs +} + +func (r *RelayerFactory) NewTron(ks keystore.Tron, chainCfgs RawConfigs) (map[types.RelayID]loop.Relayer, error) { + plugin := env.NewPlugin("tron") + loopKs := &keystore.TronLOOPKeystore{Tron: ks} + return r.NewLOOPRelayer("Tron", relay.NetworkTron, plugin, loopKs, chainCfgs) +} diff --git a/core/services/chainlink/testdata/config-invalid.toml b/core/services/chainlink/testdata/config-invalid.toml index 967ef76de8e..347530cec53 100644 --- a/core/services/chainlink/testdata/config-invalid.toml +++ b/core/services/chainlink/testdata/config-invalid.toml @@ -187,6 +187,15 @@ Name = 'primary' [[Aptos.Nodes]] Name = 'primary' +[[Tron]] +Enabled = 1 + +[[Tron.Nodes]] +Name = 'tron-test' + +[[Tron.Nodes]] +Name = 'tron-test' + [OCR2] Enabled = true diff --git a/core/services/chainlink/types.go b/core/services/chainlink/types.go index 74ffc5dc66d..53e7d2a9366 100644 --- a/core/services/chainlink/types.go +++ b/core/services/chainlink/types.go @@ -16,6 +16,7 @@ type GeneralConfig interface { SolanaConfigs() solcfg.TOMLConfigs StarknetConfigs() stkcfg.TOMLConfigs AptosConfigs() RawConfigs + TronConfigs() RawConfigs // ConfigTOML returns both the user provided and effective configuration as TOML. ConfigTOML() (user, effective string) } diff --git a/core/services/job/job_orm_test.go b/core/services/job/job_orm_test.go index 27223e0d706..7a310d6f791 100644 --- a/core/services/job/job_orm_test.go +++ b/core/services/job/job_orm_test.go @@ -1020,6 +1020,18 @@ func TestORM_ValidateKeyStoreMatch(t *testing.T) { require.NoError(t, err) }) + t.Run(("test Tron key validation"), func(t *testing.T) { + ctx := testutils.Context(t) + jb.OCR2OracleSpec.Relay = relay.NetworkTron + err := job.ValidateKeyStoreMatch(ctx, jb.OCR2OracleSpec, keyStore, "bad key") + require.EqualError(t, err, "no Tron key matching: \"bad key\"") + + tronKey, err := keyStore.Tron().Create(ctx) + require.NoError(t, err) + err = job.ValidateKeyStoreMatch(ctx, jb.OCR2OracleSpec, keyStore, tronKey.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 cfd8060d60c..fa64404ec3a 100644 --- a/core/services/job/orm.go +++ b/core/services/job/orm.go @@ -658,6 +658,11 @@ func validateKeyStoreMatchForRelay(ctx context.Context, network string, keyStore if err != nil { return errors.Errorf("no Aptos key matching: %q", key) } + case relay.NetworkTron: + _, err := keyStore.Tron().Get(key) + if err != nil { + return errors.Errorf("no Tron key matching: %q", key) + } } return nil } diff --git a/core/services/keystore/chaintype/chaintype.go b/core/services/keystore/chaintype/chaintype.go index 419dfa2d073..8aca72d4f83 100644 --- a/core/services/keystore/chaintype/chaintype.go +++ b/core/services/keystore/chaintype/chaintype.go @@ -21,6 +21,8 @@ const ( StarkNet ChainType = "starknet" // Aptos for the Aptos chain Aptos ChainType = "aptos" + // Tron for the Tron chain + Tron ChainType = "tron" ) type ChainTypes []ChainType @@ -48,6 +50,8 @@ func NewChainType(typ uint8) (ChainType, error) { return StarkNet, nil case 5: return Aptos, nil + case 6: + return Tron, nil default: return "", fmt.Errorf("unexpected chaintype.ChainType: %#v", typ) } @@ -65,13 +69,15 @@ func (c ChainType) Type() (uint8, error) { return 4, nil case Aptos: return 5, nil + case Tron: + return 6, nil default: return 0, fmt.Errorf("unexpected chaintype.ChainType: %#v", c) } } // SupportedChainTypes contain all chains that are supported -var SupportedChainTypes = ChainTypes{EVM, Cosmos, Solana, StarkNet, Aptos} +var SupportedChainTypes = ChainTypes{EVM, Cosmos, Solana, StarkNet, Aptos, Tron} // ErrInvalidChainType is an error to indicate an unsupported chain type var ErrInvalidChainType error diff --git a/core/services/keystore/keys/ocr2key/export.go b/core/services/keystore/keys/ocr2key/export.go index 8fa5ffedfed..eb7fe5f5eb9 100644 --- a/core/services/keystore/keys/ocr2key/export.go +++ b/core/services/keystore/keys/ocr2key/export.go @@ -48,6 +48,8 @@ func FromEncryptedJSON(keyJSON []byte, password string) (KeyBundle, error) { kb = newKeyBundle(new(starkkey.OCR2Key)) case chaintype.Aptos: kb = newKeyBundle(new(aptosKeyring)) + case chaintype.Tron: + kb = newKeyBundle(new(evmKeyring)) 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 b0ffa2db009..fd1e867dfa9 100644 --- a/core/services/keystore/keys/ocr2key/export_test.go +++ b/core/services/keystore/keys/ocr2key/export_test.go @@ -19,6 +19,7 @@ func TestExport(t *testing.T) { {chain: chaintype.Solana}, {chain: chaintype.StarkNet}, {chain: chaintype.Aptos}, + {chain: chaintype.Tron}, } 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 a08bd84ac30..07ac352a17d 100644 --- a/core/services/keystore/keys/ocr2key/key_bundle.go +++ b/core/services/keystore/keys/ocr2key/key_bundle.go @@ -59,6 +59,8 @@ func New(chainType chaintype.ChainType) (KeyBundle, error) { return newKeyBundleRand(chaintype.StarkNet, starkkey.NewOCR2Key) case chaintype.Aptos: return newKeyBundleRand(chaintype.Aptos, newAptosKeyring) + case chaintype.Tron: + return newKeyBundleRand(chaintype.Tron, newEVMKeyring) } return nil, chaintype.NewErrInvalidChainType(chainType) } @@ -76,6 +78,8 @@ func MustNewInsecure(reader io.Reader, chainType chaintype.ChainType) KeyBundle return mustNewKeyBundleInsecure(chaintype.StarkNet, starkkey.NewOCR2Key, reader) case chaintype.Aptos: return mustNewKeyBundleInsecure(chaintype.Aptos, newAptosKeyring, reader) + case chaintype.Tron: + return mustNewKeyBundleInsecure(chaintype.Tron, newEVMKeyring, reader) } panic(chaintype.NewErrInvalidChainType(chainType)) } @@ -126,6 +130,8 @@ func (raw Raw) Key() (kb KeyBundle) { kb = newKeyBundle(new(starkkey.OCR2Key)) case chaintype.Aptos: kb = newKeyBundle(new(aptosKeyring)) + case chaintype.Tron: + kb = newKeyBundle(new(evmKeyring)) default: return nil } diff --git a/core/services/keystore/keys/tronkey/account.go b/core/services/keystore/keys/tronkey/account.go new file mode 100644 index 00000000000..9c90422d2a7 --- /dev/null +++ b/core/services/keystore/keys/tronkey/account.go @@ -0,0 +1,178 @@ +package tronkey + +import ( + "crypto/ecdsa" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/crypto" + "github.com/mr-tron/base58" +) + +// Extracted from go-tron sdk: https://github.com/fbsobreira/gotron-sdk + +const ( + // HashLength is the expected length of the hash + HashLength = 32 + // AddressLengthBase58 is the expected length of the address in base58format + AddressLengthBase58 = 34 + // Tron Address Prefix + prefixMainnet = 0x41 + // TronBytePrefix is the hex prefix to address + TronBytePrefix = byte(prefixMainnet) + // Tron address should have 21 bytes (20 bytes + 1 byte prefix) + AddressLength = 21 +) + +// Address represents the 21 byte address of an Tron account. +type Address [AddressLength]byte + +// Bytes get bytes from address +func (a Address) Bytes() []byte { + return a[:] +} + +// Hex get bytes from address in string +func (a Address) Hex() string { + return BytesToHexString(a[:]) +} + +// HexToAddress returns Address with byte values of s. +func HexToAddress(s string) (Address, error) { + addr, err := FromHex(s) + if err != nil { + return Address{}, err + } + // Check if the address starts with '41' and is 21 characters long + if len(addr) != AddressLength || addr[0] != prefixMainnet { + return Address{}, errors.New("invalid Tron address") + } + return Address(addr), nil +} + +// Base58ToAddress returns Address with byte values of s. +func Base58ToAddress(s string) (Address, error) { + addr, err := DecodeCheck(s) + if err != nil { + return Address{}, err + } + return Address(addr), nil +} + +// String implements fmt.Stringer. +// Returns the address as a base58 encoded string. +func (a Address) String() string { + if len(a) == 0 { + return "" + } + + if a[0] == 0 { + return new(big.Int).SetBytes(a.Bytes()).String() + } + return EncodeCheck(a.Bytes()) +} + +// PubkeyToAddress returns address from ecdsa public key +func PubkeyToAddress(p ecdsa.PublicKey) Address { + address := crypto.PubkeyToAddress(p) + + addressTron := make([]byte, 0) + addressTron = append(addressTron, TronBytePrefix) + addressTron = append(addressTron, address.Bytes()...) + return Address(addressTron) +} + +// BytesToHexString encodes bytes as a hex string. +func BytesToHexString(bytes []byte) string { + encode := make([]byte, len(bytes)*2) + hex.Encode(encode, bytes) + return "0x" + string(encode) +} + +// FromHex returns the bytes represented by the hexadecimal string s. +// s may be prefixed with "0x". +func FromHex(s string) ([]byte, error) { + if Has0xPrefix(s) { + s = s[2:] + } + if len(s)%2 == 1 { + s = "0" + s + } + return HexToBytes(s) +} + +// Has0xPrefix validates str begins with '0x' or '0X'. +func Has0xPrefix(str string) bool { + return len(str) >= 2 && str[0] == '0' && (str[1] == 'x' || str[1] == 'X') +} + +// HexToBytes returns the bytes represented by the hexadecimal string str. +func HexToBytes(str string) ([]byte, error) { + return hex.DecodeString(str) +} + +func Encode(input []byte) string { + return base58.Encode(input) +} + +func EncodeCheck(input []byte) string { + h256h0 := sha256.New() + h256h0.Write(input) + h0 := h256h0.Sum(nil) + + h256h1 := sha256.New() + h256h1.Write(h0) + h1 := h256h1.Sum(nil) + + inputCheck := input + inputCheck = append(inputCheck, h1[:4]...) + + return Encode(inputCheck) +} + +func DecodeCheck(input string) ([]byte, error) { + decodeCheck, err := Decode(input) + if err != nil { + return nil, err + } + + if len(decodeCheck) < 4 { + return nil, errors.New("base58 check error") + } + + // tron address should should have 21 bytes (including prefix) + 4 checksum + if len(decodeCheck) != AddressLength+4 { + return nil, fmt.Errorf("invalid address length: %d", len(decodeCheck)) + } + + // check prefix + if decodeCheck[0] != prefixMainnet { + return nil, errors.New("invalid prefix") + } + + decodeData := decodeCheck[:len(decodeCheck)-4] + + h256h0 := sha256.New() + h256h0.Write(decodeData) + h0 := h256h0.Sum(nil) + + h256h1 := sha256.New() + h256h1.Write(h0) + h1 := h256h1.Sum(nil) + + if h1[0] == decodeCheck[len(decodeData)] && + h1[1] == decodeCheck[len(decodeData)+1] && + h1[2] == decodeCheck[len(decodeData)+2] && + h1[3] == decodeCheck[len(decodeData)+3] { + return decodeData, nil + } + + return nil, errors.New("base58 check error") +} + +func Decode(input string) ([]byte, error) { + return base58.Decode(input) +} diff --git a/core/services/keystore/keys/tronkey/account_test.go b/core/services/keystore/keys/tronkey/account_test.go new file mode 100644 index 00000000000..6047830a717 --- /dev/null +++ b/core/services/keystore/keys/tronkey/account_test.go @@ -0,0 +1,177 @@ +package tronkey + +import ( + "bytes" + "regexp" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func Test_DecodeBase58(t *testing.T) { + invalidAddresses := []string{ + "TronEnergyioE1Z3ukeRv38sYkv5Jn55bL", + "TronEnergyioNijNo8g3LF2ABKUAae6D2Z", + "TronEnergyio3ZMcXA5hSjrTxaioKGgqyr", + } + + validAddresses := []string{ + "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", + "TVj7RNVHy6thbM7BWdSe9G6gXwKhjhdNZS", + "THPvaUhoh2Qn2y9THCZML3H815hhFhn5YC", + } + + for _, addr := range invalidAddresses { + _, err := DecodeCheck(addr) + require.Error(t, err) + } + + for _, addr := range validAddresses { + _, err := DecodeCheck(addr) + require.NoError(t, err) + } +} + +func TestAddress(t *testing.T) { + t.Run("Valid Addresses", func(t *testing.T) { + validAddresses := []string{ + "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", + "TVj7RNVHy6thbM7BWdSe9G6gXwKhjhdNZS", + "THPvaUhoh2Qn2y9THCZML3H815hhFhn5YC", + } + + for _, addrStr := range validAddresses { + t.Run(addrStr, func(t *testing.T) { + addr, err := Base58ToAddress(addrStr) + require.NoError(t, err) + require.Equal(t, addrStr, addr.String()) + + decoded, err := DecodeCheck(addrStr) + require.NoError(t, err) + require.True(t, bytes.Equal(decoded, addr.Bytes())) + }) + } + }) + + t.Run("Invalid Addresses", func(t *testing.T) { + invalidAddresses := []string{ + "TronEnergyioE1Z3ukeRv38sYkv5Jn55bL", + "TronEnergyioNijNo8g3LF2ABKUAae6D2Z", + "TronEnergyio3ZMcXA5hSjrTxaioKGgqyr", + } + + for _, addrStr := range invalidAddresses { + t.Run(addrStr, func(t *testing.T) { + _, err := Base58ToAddress(addrStr) + require.Error(t, err) + + _, err = DecodeCheck(addrStr) + require.Error(t, err) + }) + } + }) + + t.Run("Address Conversion", func(t *testing.T) { + addrStr := "TSvT6Bg3siokv3dbdtt9o4oM1CTXmymGn1" + addr, err := Base58ToAddress(addrStr) + require.NoError(t, err) + + t.Run("To Bytes", func(t *testing.T) { + bytes := addr.Bytes() + require.Len(t, bytes, 21) + }) + + t.Run("To Hex", func(t *testing.T) { + hex := addr.Hex() + require.Equal(t, "0x", hex[:2]) + require.Len(t, hex, 44) + }) + }) + + t.Run("Address Validity", func(t *testing.T) { + t.Run("Valid Address", func(t *testing.T) { + addr, err := Base58ToAddress("TSvT6Bg3siokv3dbdtt9o4oM1CTXmymGn1") + require.NoError(t, err) + require.True(t, isValid(addr)) + }) + + t.Run("Zero Address", func(t *testing.T) { + addr := Address{} + require.False(t, isValid(addr)) + }) + }) +} + +func TestHexToAddress(t *testing.T) { + t.Run("Valid Hex Addresses", func(t *testing.T) { + validHexAddresses := []string{ + "41a614f803b6fd780986a42c78ec9c7f77e6ded13c", + "41b2a2e1b2e1b2e1b2e1b2e1b2e1b2e1b2e1b2e1b2", + "41c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3", + } + + for _, hexStr := range validHexAddresses { + t.Run(hexStr, func(t *testing.T) { + addr, err := HexToAddress(hexStr) + require.Nil(t, err) + require.Equal(t, "0x"+hexStr, addr.Hex()) + }) + } + }) + + t.Run("Invalid Hex Addresses", func(t *testing.T) { + invalidHexAddresses := []string{ + "41a614f803b6fd780986a42c78ec9c7f77e6ded13", // Too short + "41b2a2e1b2e1b2e1b2e1b2e1b2e1b2e1b2e1b2e1b2e1b2", // Too long + "41g3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3", // Invalid character 'g' + "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3", // Missing prefix '41' + } + + for _, hexStr := range invalidHexAddresses { + t.Run(hexStr, func(t *testing.T) { + _, err := HexToAddress(hexStr) + require.NotNil(t, err) + }) + } + }) +} + +// Helper Functions for testing + +// isValid checks if the address is a valid TRON address +func isValid(a Address) bool { + // Check if it's a valid Base58 address + base58Str := a.String() + if isValidBase58Address(base58Str) { + return true + } + + // Check if it's a valid hex address + hexStr := a.Hex() + return isValidHexAddress(strings.TrimPrefix(hexStr, "0x")) +} + +// isValidBase58Address check if a string is a valid Base58 TRON address +func isValidBase58Address(address string) bool { + // Check if the address starts with 'T' and is 34 characters long + if len(address) != 34 || address[0] != 'T' { + return false + } + + // Check if the address contains only valid Base58 characters + validChars := regexp.MustCompile("^[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]+$") + return validChars.MatchString(address) +} + +// isValidHexAddressto check if a string is a valid hex TRON address +func isValidHexAddress(address string) bool { + // Check if the address starts with '41' and is 42 characters long + if len(address) != 42 || address[:2] != "41" { + return false + } + + // Check if the address contains only valid hexadecimal characters + validChars := regexp.MustCompile("^[0-9A-Fa-f]+$") + return validChars.MatchString(address[2:]) // Check the part after '41' +} diff --git a/core/services/keystore/keys/tronkey/export.go b/core/services/keystore/keys/tronkey/export.go new file mode 100644 index 00000000000..7688650c58d --- /dev/null +++ b/core/services/keystore/keys/tronkey/export.go @@ -0,0 +1,46 @@ +package tronkey + +import ( + "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 = "Tron" + +// 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: key.PublicKeyStr(), + Crypto: cryptoJSON, + } + }, + ) +} + +func adulteratedPassword(password string) string { + return "tronkey" + password +} diff --git a/core/services/keystore/keys/tronkey/export_test.go b/core/services/keystore/keys/tronkey/export_test.go new file mode 100644 index 00000000000..5e3e605ed34 --- /dev/null +++ b/core/services/keystore/keys/tronkey/export_test.go @@ -0,0 +1,19 @@ +package tronkey + +import ( + "testing" + + "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys" +) + +func TestTronKeys_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/tronkey/key.go b/core/services/keystore/keys/tronkey/key.go new file mode 100644 index 00000000000..5f5b36b8c14 --- /dev/null +++ b/core/services/keystore/keys/tronkey/key.go @@ -0,0 +1,109 @@ +package tronkey + +import ( + "crypto/ecdsa" + "crypto/rand" + "encoding/hex" + "fmt" + "io" + "math/big" + + "github.com/ethereum/go-ethereum/crypto" +) + +// Tron uses the same elliptic curve cryptography as Ethereum (ECDSA with secp256k1) +var curve = crypto.S256() + +// Raw represents the Tron private key +type Raw []byte + +// Key generates a public-private key pair from the raw private key +func (raw Raw) Key() Key { + var privKey ecdsa.PrivateKey + d := big.NewInt(0).SetBytes(raw) + privKey.PublicKey.Curve = curve + privKey.D = d + privKey.PublicKey.X, privKey.PublicKey.Y = curve.ScalarBaseMult(d.Bytes()) + return Key{ + pubKey: &privKey.PublicKey, + privKey: &privKey, + } +} + +func (raw Raw) String() string { + return "" +} + +func (raw Raw) GoString() string { + return raw.String() +} + +var _ fmt.GoStringer = &Key{} + +type Key struct { + privKey *ecdsa.PrivateKey + pubKey *ecdsa.PublicKey +} + +func New() (Key, error) { + return newFrom(rand.Reader) +} + +// MustNewInsecure return Key if no error +// This insecure function is used for testing purposes only +func MustNewInsecure(reader io.Reader) Key { + key, err := newFrom(reader) + if err != nil { + panic(err) + } + return key +} + +func newFrom(reader io.Reader) (Key, error) { + privKeyECDSA, err := ecdsa.GenerateKey(curve, reader) + if err != nil { + return Key{}, err + } + return Key{ + privKey: privKeyECDSA, + pubKey: &privKeyECDSA.PublicKey, + }, nil +} + +func (key Key) ID() string { + return key.Base58Address() +} + +func (key Key) Raw() Raw { + return key.privKey.D.Bytes() +} + +func (key Key) ToEcdsaPrivKey() *ecdsa.PrivateKey { + return key.privKey +} + +func (key Key) String() string { + return fmt.Sprintf("TronKey{PrivateKey: , Address: %s}", key.Base58Address()) +} + +// 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 crypto.Sign(msg, key.privKey) +} + +// PublicKeyStr returns the public key as a hexadecimal string +func (key Key) PublicKeyStr() string { + pubKeyBytes := crypto.FromECDSAPub(key.pubKey) + return hex.EncodeToString(pubKeyBytes) +} + +// Base58Address returns the Tron address in Base58 format with checksum +func (key Key) Base58Address() string { + address := PubkeyToAddress(*key.pubKey) + return address.String() +} diff --git a/core/services/keystore/keys/tronkey/key_test.go b/core/services/keystore/keys/tronkey/key_test.go new file mode 100644 index 00000000000..d3714228483 --- /dev/null +++ b/core/services/keystore/keys/tronkey/key_test.go @@ -0,0 +1,85 @@ +package tronkey + +import ( + "crypto/ecdsa" + "crypto/rand" + "encoding/hex" + "testing" + + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTronKeyRawPrivateKey(t *testing.T) { + t.Run("Create from raw bytes and check string representation", func(t *testing.T) { + // Generate a private key + privateKeyECDSA, err := ecdsa.GenerateKey(crypto.S256(), rand.Reader) + require.NoError(t, err, "Failed to generate ECDSA key") + + // Create TronKey from raw bytes + tronKey := Raw(privateKeyECDSA.D.Bytes()) + + // Check string representation + expectedStr := "" + assert.Equal(t, expectedStr, tronKey.String(), "Unexpected string representation") + assert.Equal(t, expectedStr, tronKey.GoString(), "String() and GoString() should return the same value") + }) +} + +func TestTronKeyNewKeyGeneration(t *testing.T) { + t.Run("Generate new key and verify its components", func(t *testing.T) { + // Generate a new key + key, err := New() + require.NoError(t, err, "Failed to generate new TronKey") + + // Verify key components + assert.NotNil(t, key.pubKey, "Public key should not be nil") + assert.NotNil(t, key.privKey, "Private key should not be nil") + }) + + t.Run("Multiple key generations produce unique keys", func(t *testing.T) { + key1, err := New() + require.NoError(t, err, "Failed to generate first key") + + key2, err := New() + require.NoError(t, err, "Failed to generate second key") + + assert.NotEqual(t, key1.privKey, key2.privKey, "Generated private keys should be unique") + assert.NotEqual(t, key1.pubKey, key2.pubKey, "Generated public keys should be unique") + }) +} + +func TestKeyAddress(t *testing.T) { + t.Run("Known private key and expected address", func(t *testing.T) { + // Tests cases from https://developers.tron.network/docs/account + privateKeyHex := "b406adb115b43e103c7b1dc8b5931f63279a5b6b2cf7328638814c43171a2908" + expectedAddress := "TDdcf5iMDkB61oGM27TNak55eVX214thBG" + + privateKeyBytes, err := hex.DecodeString(privateKeyHex) + require.NoError(t, err, "Failed to decode private key hex") + + privateKey, err := crypto.ToECDSA(privateKeyBytes) + require.NoError(t, err, "Failed to convert private key to ECDSA") + + key := Key{ + privKey: privateKey, + pubKey: &privateKey.PublicKey, + } + require.NotNil(t, key.privKey, "Private key is nil") + + address := key.Base58Address() + require.Equal(t, expectedAddress, address, "Generated address does not match expected address") + }) + + t.Run("Generate new key and check address format", func(t *testing.T) { + newKey, err := New() + if err != nil { + t.Fatalf("Failed to generate new key: %v", err) + } + + newAddress := newKey.Base58Address() + isValid := isValidBase58Address(newAddress) + require.True(t, isValid, "Generated address is not valid") + }) +} diff --git a/core/services/keystore/keystoretest.go b/core/services/keystore/keystoretest.go index 626cc4bab99..9814801b2a5 100644 --- a/core/services/keystore/keystoretest.go +++ b/core/services/keystore/keystoretest.go @@ -74,6 +74,7 @@ func NewInMemory(ds sqlutil.DataSource, scryptParams utils.ScryptParams, lggr lo solana: newSolanaKeyStore(km), starknet: newStarkNetKeyStore(km), aptos: newAptosKeyStore(km), + tron: newTronKeyStore(km), vrf: newVRFKeyStore(km), workflow: newWorkflowKeyStore(km), } diff --git a/core/services/keystore/master.go b/core/services/keystore/master.go index 72677a166a3..50ca6d0c34d 100644 --- a/core/services/keystore/master.go +++ b/core/services/keystore/master.go @@ -20,6 +20,7 @@ import ( "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/p2pkey" "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/solkey" "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/starkkey" + "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/tronkey" "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/vrfkey" "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/workflowkey" "github.com/smartcontractkit/chainlink/v2/core/utils" @@ -45,6 +46,7 @@ type Master interface { Cosmos() Cosmos StarkNet() StarkNet Aptos() Aptos + Tron() Tron VRF() VRF Workflow() Workflow Unlock(ctx context.Context, password string) error @@ -62,6 +64,7 @@ type master struct { solana *solana starknet *starknet aptos *aptos + tron *tron vrf *vrf workflow *workflow } @@ -91,6 +94,7 @@ func newMaster(ds sqlutil.DataSource, scryptParams utils.ScryptParams, lggr logg solana: newSolanaKeyStore(km), starknet: newStarkNetKeyStore(km), aptos: newAptosKeyStore(km), + tron: newTronKeyStore(km), vrf: newVRFKeyStore(km), workflow: newWorkflowKeyStore(km), } @@ -132,6 +136,10 @@ func (ks *master) Aptos() Aptos { return ks.aptos } +func (ks *master) Tron() Tron { + return ks.tron +} + func (ks *master) VRF() VRF { return ks.vrf } @@ -273,6 +281,8 @@ func GetFieldNameForKey(unknownKey Key) (string, error) { return "StarkNet", nil case aptoskey.Key: return "Aptos", nil + case tronkey.Key: + return "Tron", nil case vrfkey.KeyV2: return "VRF", nil case workflowkey.Key: diff --git a/core/services/keystore/mocks/master.go b/core/services/keystore/mocks/master.go index 7c86001bc54..58a0f2f8887 100644 --- a/core/services/keystore/mocks/master.go +++ b/core/services/keystore/mocks/master.go @@ -501,6 +501,53 @@ func (_c *Master_StarkNet_Call) RunAndReturn(run func() keystore.StarkNet) *Mast return _c } +// Tron provides a mock function with given fields: +func (_m *Master) Tron() keystore.Tron { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Tron") + } + + var r0 keystore.Tron + if rf, ok := ret.Get(0).(func() keystore.Tron); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(keystore.Tron) + } + } + + return r0 +} + +// Master_Tron_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Tron' +type Master_Tron_Call struct { + *mock.Call +} + +// Tron is a helper method to define mock.On call +func (_e *Master_Expecter) Tron() *Master_Tron_Call { + return &Master_Tron_Call{Call: _e.mock.On("Tron")} +} + +func (_c *Master_Tron_Call) Run(run func()) *Master_Tron_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *Master_Tron_Call) Return(_a0 keystore.Tron) *Master_Tron_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Master_Tron_Call) RunAndReturn(run func() keystore.Tron) *Master_Tron_Call { + _c.Call.Return(run) + return _c +} + // Unlock provides a mock function with given fields: ctx, password func (_m *Master) Unlock(ctx context.Context, password string) error { ret := _m.Called(ctx, password) diff --git a/core/services/keystore/mocks/tron.go b/core/services/keystore/mocks/tron.go new file mode 100644 index 00000000000..18152a0c80b --- /dev/null +++ b/core/services/keystore/mocks/tron.go @@ -0,0 +1,534 @@ +// Code generated by mockery v2.46.3. DO NOT EDIT. + +package mocks + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" + + tronkey "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/tronkey" +) + +// Tron is an autogenerated mock type for the Tron type +type Tron struct { + mock.Mock +} + +type Tron_Expecter struct { + mock *mock.Mock +} + +func (_m *Tron) EXPECT() *Tron_Expecter { + return &Tron_Expecter{mock: &_m.Mock} +} + +// Add provides a mock function with given fields: ctx, key +func (_m *Tron) Add(ctx context.Context, key tronkey.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, tronkey.Key) error); ok { + r0 = rf(ctx, key) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Tron_Add_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Add' +type Tron_Add_Call struct { + *mock.Call +} + +// Add is a helper method to define mock.On call +// - ctx context.Context +// - key tronkey.Key +func (_e *Tron_Expecter) Add(ctx interface{}, key interface{}) *Tron_Add_Call { + return &Tron_Add_Call{Call: _e.mock.On("Add", ctx, key)} +} + +func (_c *Tron_Add_Call) Run(run func(ctx context.Context, key tronkey.Key)) *Tron_Add_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(tronkey.Key)) + }) + return _c +} + +func (_c *Tron_Add_Call) Return(_a0 error) *Tron_Add_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Tron_Add_Call) RunAndReturn(run func(context.Context, tronkey.Key) error) *Tron_Add_Call { + _c.Call.Return(run) + return _c +} + +// Create provides a mock function with given fields: ctx +func (_m *Tron) Create(ctx context.Context) (tronkey.Key, error) { + ret := _m.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for Create") + } + + var r0 tronkey.Key + var r1 error + if rf, ok := ret.Get(0).(func(context.Context) (tronkey.Key, error)); ok { + return rf(ctx) + } + if rf, ok := ret.Get(0).(func(context.Context) tronkey.Key); ok { + r0 = rf(ctx) + } else { + r0 = ret.Get(0).(tronkey.Key) + } + + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Tron_Create_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Create' +type Tron_Create_Call struct { + *mock.Call +} + +// Create is a helper method to define mock.On call +// - ctx context.Context +func (_e *Tron_Expecter) Create(ctx interface{}) *Tron_Create_Call { + return &Tron_Create_Call{Call: _e.mock.On("Create", ctx)} +} + +func (_c *Tron_Create_Call) Run(run func(ctx context.Context)) *Tron_Create_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context)) + }) + return _c +} + +func (_c *Tron_Create_Call) Return(_a0 tronkey.Key, _a1 error) *Tron_Create_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Tron_Create_Call) RunAndReturn(run func(context.Context) (tronkey.Key, error)) *Tron_Create_Call { + _c.Call.Return(run) + return _c +} + +// Delete provides a mock function with given fields: ctx, id +func (_m *Tron) Delete(ctx context.Context, id string) (tronkey.Key, error) { + ret := _m.Called(ctx, id) + + if len(ret) == 0 { + panic("no return value specified for Delete") + } + + var r0 tronkey.Key + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) (tronkey.Key, error)); ok { + return rf(ctx, id) + } + if rf, ok := ret.Get(0).(func(context.Context, string) tronkey.Key); ok { + r0 = rf(ctx, id) + } else { + r0 = ret.Get(0).(tronkey.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 +} + +// Tron_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete' +type Tron_Delete_Call struct { + *mock.Call +} + +// Delete is a helper method to define mock.On call +// - ctx context.Context +// - id string +func (_e *Tron_Expecter) Delete(ctx interface{}, id interface{}) *Tron_Delete_Call { + return &Tron_Delete_Call{Call: _e.mock.On("Delete", ctx, id)} +} + +func (_c *Tron_Delete_Call) Run(run func(ctx context.Context, id string)) *Tron_Delete_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *Tron_Delete_Call) Return(_a0 tronkey.Key, _a1 error) *Tron_Delete_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Tron_Delete_Call) RunAndReturn(run func(context.Context, string) (tronkey.Key, error)) *Tron_Delete_Call { + _c.Call.Return(run) + return _c +} + +// EnsureKey provides a mock function with given fields: ctx +func (_m *Tron) 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 +} + +// Tron_EnsureKey_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnsureKey' +type Tron_EnsureKey_Call struct { + *mock.Call +} + +// EnsureKey is a helper method to define mock.On call +// - ctx context.Context +func (_e *Tron_Expecter) EnsureKey(ctx interface{}) *Tron_EnsureKey_Call { + return &Tron_EnsureKey_Call{Call: _e.mock.On("EnsureKey", ctx)} +} + +func (_c *Tron_EnsureKey_Call) Run(run func(ctx context.Context)) *Tron_EnsureKey_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context)) + }) + return _c +} + +func (_c *Tron_EnsureKey_Call) Return(_a0 error) *Tron_EnsureKey_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Tron_EnsureKey_Call) RunAndReturn(run func(context.Context) error) *Tron_EnsureKey_Call { + _c.Call.Return(run) + return _c +} + +// Export provides a mock function with given fields: id, password +func (_m *Tron) 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 +} + +// Tron_Export_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Export' +type Tron_Export_Call struct { + *mock.Call +} + +// Export is a helper method to define mock.On call +// - id string +// - password string +func (_e *Tron_Expecter) Export(id interface{}, password interface{}) *Tron_Export_Call { + return &Tron_Export_Call{Call: _e.mock.On("Export", id, password)} +} + +func (_c *Tron_Export_Call) Run(run func(id string, password string)) *Tron_Export_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(string)) + }) + return _c +} + +func (_c *Tron_Export_Call) Return(_a0 []byte, _a1 error) *Tron_Export_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Tron_Export_Call) RunAndReturn(run func(string, string) ([]byte, error)) *Tron_Export_Call { + _c.Call.Return(run) + return _c +} + +// Get provides a mock function with given fields: id +func (_m *Tron) Get(id string) (tronkey.Key, error) { + ret := _m.Called(id) + + if len(ret) == 0 { + panic("no return value specified for Get") + } + + var r0 tronkey.Key + var r1 error + if rf, ok := ret.Get(0).(func(string) (tronkey.Key, error)); ok { + return rf(id) + } + if rf, ok := ret.Get(0).(func(string) tronkey.Key); ok { + r0 = rf(id) + } else { + r0 = ret.Get(0).(tronkey.Key) + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Tron_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get' +type Tron_Get_Call struct { + *mock.Call +} + +// Get is a helper method to define mock.On call +// - id string +func (_e *Tron_Expecter) Get(id interface{}) *Tron_Get_Call { + return &Tron_Get_Call{Call: _e.mock.On("Get", id)} +} + +func (_c *Tron_Get_Call) Run(run func(id string)) *Tron_Get_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *Tron_Get_Call) Return(_a0 tronkey.Key, _a1 error) *Tron_Get_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Tron_Get_Call) RunAndReturn(run func(string) (tronkey.Key, error)) *Tron_Get_Call { + _c.Call.Return(run) + return _c +} + +// GetAll provides a mock function with given fields: +func (_m *Tron) GetAll() ([]tronkey.Key, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetAll") + } + + var r0 []tronkey.Key + var r1 error + if rf, ok := ret.Get(0).(func() ([]tronkey.Key, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() []tronkey.Key); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]tronkey.Key) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Tron_GetAll_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAll' +type Tron_GetAll_Call struct { + *mock.Call +} + +// GetAll is a helper method to define mock.On call +func (_e *Tron_Expecter) GetAll() *Tron_GetAll_Call { + return &Tron_GetAll_Call{Call: _e.mock.On("GetAll")} +} + +func (_c *Tron_GetAll_Call) Run(run func()) *Tron_GetAll_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *Tron_GetAll_Call) Return(_a0 []tronkey.Key, _a1 error) *Tron_GetAll_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Tron_GetAll_Call) RunAndReturn(run func() ([]tronkey.Key, error)) *Tron_GetAll_Call { + _c.Call.Return(run) + return _c +} + +// Import provides a mock function with given fields: ctx, keyJSON, password +func (_m *Tron) Import(ctx context.Context, keyJSON []byte, password string) (tronkey.Key, error) { + ret := _m.Called(ctx, keyJSON, password) + + if len(ret) == 0 { + panic("no return value specified for Import") + } + + var r0 tronkey.Key + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, []byte, string) (tronkey.Key, error)); ok { + return rf(ctx, keyJSON, password) + } + if rf, ok := ret.Get(0).(func(context.Context, []byte, string) tronkey.Key); ok { + r0 = rf(ctx, keyJSON, password) + } else { + r0 = ret.Get(0).(tronkey.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 +} + +// Tron_Import_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Import' +type Tron_Import_Call struct { + *mock.Call +} + +// Import is a helper method to define mock.On call +// - ctx context.Context +// - keyJSON []byte +// - password string +func (_e *Tron_Expecter) Import(ctx interface{}, keyJSON interface{}, password interface{}) *Tron_Import_Call { + return &Tron_Import_Call{Call: _e.mock.On("Import", ctx, keyJSON, password)} +} + +func (_c *Tron_Import_Call) Run(run func(ctx context.Context, keyJSON []byte, password string)) *Tron_Import_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].([]byte), args[2].(string)) + }) + return _c +} + +func (_c *Tron_Import_Call) Return(_a0 tronkey.Key, _a1 error) *Tron_Import_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Tron_Import_Call) RunAndReturn(run func(context.Context, []byte, string) (tronkey.Key, error)) *Tron_Import_Call { + _c.Call.Return(run) + return _c +} + +// Sign provides a mock function with given fields: ctx, id, msg +func (_m *Tron) 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 +} + +// Tron_Sign_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Sign' +type Tron_Sign_Call struct { + *mock.Call +} + +// Sign is a helper method to define mock.On call +// - ctx context.Context +// - id string +// - msg []byte +func (_e *Tron_Expecter) Sign(ctx interface{}, id interface{}, msg interface{}) *Tron_Sign_Call { + return &Tron_Sign_Call{Call: _e.mock.On("Sign", ctx, id, msg)} +} + +func (_c *Tron_Sign_Call) Run(run func(ctx context.Context, id string, msg []byte)) *Tron_Sign_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].([]byte)) + }) + return _c +} + +func (_c *Tron_Sign_Call) Return(signature []byte, err error) *Tron_Sign_Call { + _c.Call.Return(signature, err) + return _c +} + +func (_c *Tron_Sign_Call) RunAndReturn(run func(context.Context, string, []byte) ([]byte, error)) *Tron_Sign_Call { + _c.Call.Return(run) + return _c +} + +// NewTron creates a new instance of Tron. 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 NewTron(t interface { + mock.TestingT + Cleanup(func()) +}) *Tron { + mock := &Tron{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/core/services/keystore/models.go b/core/services/keystore/models.go index 151934827c3..1ebc7480997 100644 --- a/core/services/keystore/models.go +++ b/core/services/keystore/models.go @@ -20,6 +20,7 @@ import ( "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/p2pkey" "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/solkey" "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/starkkey" + "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/tronkey" "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/vrfkey" "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/workflowkey" "github.com/smartcontractkit/chainlink/v2/core/utils" @@ -158,6 +159,7 @@ type keyRing struct { Solana map[string]solkey.Key StarkNet map[string]starkkey.Key Aptos map[string]aptoskey.Key + Tron map[string]tronkey.Key VRF map[string]vrfkey.KeyV2 Workflow map[string]workflowkey.Key LegacyKeys LegacyKeyStorage @@ -174,6 +176,7 @@ func newKeyRing() *keyRing { Solana: make(map[string]solkey.Key), StarkNet: make(map[string]starkkey.Key), Aptos: make(map[string]aptoskey.Key), + Tron: make(map[string]tronkey.Key), VRF: make(map[string]vrfkey.KeyV2), Workflow: make(map[string]workflowkey.Key), } @@ -236,6 +239,9 @@ func (kr *keyRing) raw() (rawKeys rawKeyRing) { for _, aptoskey := range kr.Aptos { rawKeys.Aptos = append(rawKeys.Aptos, aptoskey.Raw()) } + for _, tronkey := range kr.Tron { + rawKeys.Tron = append(rawKeys.Tron, tronkey.Raw()) + } for _, vrfKey := range kr.VRF { rawKeys.VRF = append(rawKeys.VRF, vrfKey.Raw()) } @@ -283,6 +289,10 @@ func (kr *keyRing) logPubKeys(lggr logger.Logger) { for _, aptosKey := range kr.Aptos { aptosIDs = append(aptosIDs, aptosKey.ID()) } + tronIDs := []string{} + for _, tronKey := range kr.Tron { + tronIDs = append(tronIDs, tronKey.ID()) + } var vrfIDs []string for _, VRFKey := range kr.VRF { vrfIDs = append(vrfIDs, VRFKey.ID()) @@ -320,6 +330,9 @@ func (kr *keyRing) logPubKeys(lggr logger.Logger) { if len(aptosIDs) > 0 { lggr.Infow(fmt.Sprintf("Unlocked %d Aptos keys", len(aptosIDs)), "keys", aptosIDs) } + if len(tronIDs) > 0 { + lggr.Infow(fmt.Sprintf("Unlocked %d Tron keys", len(tronIDs)), "keys", tronIDs) + } if len(vrfIDs) > 0 { lggr.Infow(fmt.Sprintf("Unlocked %d VRF keys", len(vrfIDs)), "keys", vrfIDs) } @@ -344,6 +357,7 @@ type rawKeyRing struct { Solana []solkey.Raw StarkNet []starkkey.Raw Aptos []aptoskey.Raw + Tron []tronkey.Raw VRF []vrfkey.Raw Workflow []workflowkey.Raw LegacyKeys LegacyKeyStorage `json:"-"` @@ -388,6 +402,10 @@ func (rawKeys rawKeyRing) keys() (*keyRing, error) { aptosKey := rawAptosKey.Key() keyRing.Aptos[aptosKey.ID()] = aptosKey } + for _, rawTronKey := range rawKeys.Tron { + tronKey := rawTronKey.Key() + keyRing.Tron[tronKey.ID()] = tronKey + } for _, rawVRFKey := range rawKeys.VRF { vrfKey := rawVRFKey.Key() keyRing.VRF[vrfKey.ID()] = vrfKey diff --git a/core/services/keystore/models_test.go b/core/services/keystore/models_test.go index a828fbbf4f6..a66e29865d1 100644 --- a/core/services/keystore/models_test.go +++ b/core/services/keystore/models_test.go @@ -16,6 +16,7 @@ import ( "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/ocrkey" "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/p2pkey" "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/solkey" + "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/tronkey" "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/vrfkey" "github.com/smartcontractkit/chainlink/v2/core/utils" ) @@ -40,6 +41,7 @@ func TestKeyRing_Encrypt_Decrypt(t *testing.T) { sol1, sol2 := solkey.MustNewInsecure(rand.Reader), solkey.MustNewInsecure(rand.Reader) vrf1, vrf2 := vrfkey.MustNewV2XXXTestingOnly(big.NewInt(1)), vrfkey.MustNewV2XXXTestingOnly(big.NewInt(2)) tk1, tk2 := cosmoskey.MustNewInsecure(rand.Reader), cosmoskey.MustNewInsecure(rand.Reader) + uk1, uk2 := tronkey.MustNewInsecure(rand.Reader), tronkey.MustNewInsecure(rand.Reader) originalKeyRingRaw := rawKeyRing{ CSA: []csakey.Raw{csa1.Raw(), csa2.Raw()}, Eth: []ethkey.Raw{eth1.Raw(), eth2.Raw()}, @@ -49,6 +51,7 @@ func TestKeyRing_Encrypt_Decrypt(t *testing.T) { Solana: []solkey.Raw{sol1.Raw(), sol2.Raw()}, VRF: []vrfkey.Raw{vrf1.Raw(), vrf2.Raw()}, Cosmos: []cosmoskey.Raw{tk1.Raw(), tk2.Raw()}, + Tron: []tronkey.Raw{uk1.Raw(), uk2.Raw()}, } originalKeyRing, kerr := originalKeyRingRaw.keys() require.NoError(t, kerr) @@ -62,6 +65,10 @@ func TestKeyRing_Encrypt_Decrypt(t *testing.T) { require.Equal(t, 2, len(decryptedKeyRing.Cosmos)) require.Equal(t, originalKeyRing.Cosmos[tk1.ID()].PublicKey(), decryptedKeyRing.Cosmos[tk1.ID()].PublicKey()) require.Equal(t, originalKeyRing.Cosmos[tk2.ID()].PublicKey(), decryptedKeyRing.Cosmos[tk2.ID()].PublicKey()) + // compare tron keys + require.Len(t, decryptedKeyRing.Tron, 2) + require.Equal(t, originalKeyRing.Tron[uk1.ID()].Base58Address(), decryptedKeyRing.Tron[uk1.ID()].Base58Address()) + require.Equal(t, originalKeyRing.Tron[uk2.ID()].Base58Address(), decryptedKeyRing.Tron[uk2.ID()].Base58Address()) // compare csa keys require.Equal(t, 2, len(decryptedKeyRing.CSA)) require.Equal(t, originalKeyRing.CSA[csa1.ID()].PublicKey, decryptedKeyRing.CSA[csa1.ID()].PublicKey) diff --git a/core/services/keystore/tron.go b/core/services/keystore/tron.go new file mode 100644 index 00000000000..d5302d572b0 --- /dev/null +++ b/core/services/keystore/tron.go @@ -0,0 +1,187 @@ +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/tronkey" +) + +type Tron interface { + Get(id string) (tronkey.Key, error) + GetAll() ([]tronkey.Key, error) + Create(ctx context.Context) (tronkey.Key, error) + Add(ctx context.Context, key tronkey.Key) error + Delete(ctx context.Context, id string) (tronkey.Key, error) + Import(ctx context.Context, keyJSON []byte, password string) (tronkey.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 tron struct { + *keyManager +} + +var _ Tron = &tron{} + +func newTronKeyStore(km *keyManager) *tron { + return &tron{ + km, + } +} + +func (ks *tron) Get(id string) (tronkey.Key, error) { + ks.lock.RLock() + defer ks.lock.RUnlock() + if ks.isLocked() { + return tronkey.Key{}, ErrLocked + } + return ks.getByID(id) +} + +func (ks *tron) GetAll() (keys []tronkey.Key, _ error) { + ks.lock.RLock() + defer ks.lock.RUnlock() + if ks.isLocked() { + return nil, ErrLocked + } + for _, key := range ks.keyRing.Tron { + keys = append(keys, key) + } + return keys, nil +} + +func (ks *tron) Create(ctx context.Context) (tronkey.Key, error) { + ks.lock.Lock() + defer ks.lock.Unlock() + if ks.isLocked() { + return tronkey.Key{}, ErrLocked + } + key, err := tronkey.New() + if err != nil { + return tronkey.Key{}, err + } + return key, ks.safeAddKey(ctx, key) +} + +func (ks *tron) Add(ctx context.Context, key tronkey.Key) error { + ks.lock.Lock() + defer ks.lock.Unlock() + if ks.isLocked() { + return ErrLocked + } + if _, found := ks.keyRing.Tron[key.ID()]; found { + return fmt.Errorf("key with ID %s already exists", key.ID()) + } + return ks.safeAddKey(ctx, key) +} + +func (ks *tron) Delete(ctx context.Context, id string) (tronkey.Key, error) { + ks.lock.Lock() + defer ks.lock.Unlock() + if ks.isLocked() { + return tronkey.Key{}, ErrLocked + } + key, err := ks.getByID(id) + if err != nil { + return tronkey.Key{}, err + } + err = ks.safeRemoveKey(ctx, key) + return key, err +} + +func (ks *tron) Import(ctx context.Context, keyJSON []byte, password string) (tronkey.Key, error) { + ks.lock.Lock() + defer ks.lock.Unlock() + if ks.isLocked() { + return tronkey.Key{}, ErrLocked + } + key, err := tronkey.FromEncryptedJSON(keyJSON, password) + if err != nil { + return tronkey.Key{}, errors.Wrap(err, "TronKeyStore#ImportKey failed to decrypt key") + } + if _, found := ks.keyRing.Tron[key.ID()]; found { + return tronkey.Key{}, fmt.Errorf("key with ID %s already exists", key.ID()) + } + return key, ks.keyManager.safeAddKey(ctx, key) +} + +func (ks *tron) 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 *tron) EnsureKey(ctx context.Context) error { + ks.lock.Lock() + defer ks.lock.Unlock() + if ks.isLocked() { + return ErrLocked + } + + if len(ks.keyRing.Tron) > 0 { + return nil + } + + key, err := tronkey.New() + if err != nil { + return err + } + + ks.logger.Infof("Created Tron key with ID %s", key.ID()) + + return ks.safeAddKey(ctx, key) +} + +func (ks *tron) getByID(id string) (tronkey.Key, error) { + key, found := ks.keyRing.Tron[id] + if !found { + return tronkey.Key{}, KeyNotFoundError{ID: id, KeyType: "Tron"} + } + return key, nil +} + +func (ks *tron) Sign(_ context.Context, id string, msg []byte) (signature []byte, err error) { + k, err := ks.Get(id) + if err != nil { + return nil, err + } + // loopp spec requires passing nil hash to check existence of id + if msg == nil { + return nil, nil + } + return k.Sign(msg) +} + +// TronLOOPKeystore implements the [github.com/smartcontractkit/chainlink-common/pkg/loop.Keystore] interface and +// handles signing for Tron messages. +type TronLOOPKeystore struct { + Tron +} + +var _ loop.Keystore = &TronLOOPKeystore{} + +func (lk *TronLOOPKeystore) Accounts(ctx context.Context) ([]string, error) { + keys, err := lk.GetAll() + if err != nil { + return nil, err + } + + accounts := []string{} + for _, k := range keys { + accounts = append(accounts, k.PublicKeyStr()) + } + + return accounts, nil +} diff --git a/core/services/keystore/tron_test.go b/core/services/keystore/tron_test.go new file mode 100644 index 00000000000..2450e573898 --- /dev/null +++ b/core/services/keystore/tron_test.go @@ -0,0 +1,240 @@ +package keystore_test + +import ( + "context" + "crypto/sha256" + "encoding/json" + "testing" + "time" + + "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/tronkey" +) + +func Test_TronKeyStore_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.Tron() + 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.Empty(t, 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) + require.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) + require.Error(t, err) + _, err = ks.Import(ctx, []byte(""), cltest.Password) + require.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 := tronkey.New() + require.NoError(t, err) + err = ks.Add(ctx, newKey) + require.NoError(t, err) + err = ks.Add(ctx, newKey) + require.Error(t, err) + keys, err := ks.GetAll() + require.NoError(t, err) + require.Len(t, keys, 1) + _, err = ks.Delete(ctx, newKey.ID()) + require.NoError(t, err) + _, err = ks.Delete(ctx, newKey.ID()) + require.Error(t, err) + keys, err = ks.GetAll() + require.NoError(t, err) + require.Empty(t, 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) + require.NoError(t, err) + + err = ks.EnsureKey(ctx) + require.NoError(t, err) + + keys, err := ks.GetAll() + require.NoError(t, err) + require.Len(t, keys, 1) + }) + + t.Run("sign tx", func(t *testing.T) { + defer reset() + ctx := testutils.Context(t) + newKey, err := tronkey.New() + require.NoError(t, err) + require.NoError(t, ks.Add(ctx, newKey)) + + // sign unknown ID + _, err = ks.Sign(testutils.Context(t), "not-real", nil) + require.Error(t, err) + + // sign known key + + // Create a mock transaction + mockTx := createMockTronTransaction(newKey.PublicKeyStr(), "TJRabPrwbZy45sbavfcjinPJC18kjpRTv8", 1000000) + serializedTx, err := serializeMockTransaction(mockTx) + require.NoError(t, err) + + hash := sha256.Sum256(serializedTx) + txHash := hash[:] + sig, err := ks.Sign(testutils.Context(t), newKey.ID(), txHash) + require.NoError(t, err) + + directSig, err := newKey.Sign(txHash) + require.NoError(t, err) + + // signatures should match using keystore sign or key sign + require.Equal(t, directSig, sig) + }) +} + +// MockTronTransaction represents a mock TRON transaction +// This is based on https://developers.tron.network/docs/tron-protocol-transaction +type MockTronTransaction struct { + RawData struct { + Contract []struct { + Parameter struct { + Value struct { + Amount int64 `json:"amount"` + OwnerAddress string `json:"owner_address"` + ToAddress string `json:"to_address"` + } `json:"value"` + TypeURL string `json:"type_url"` + } `json:"parameter"` + Type string `json:"type"` + } `json:"contract"` + RefBlockBytes string `json:"ref_block_bytes"` + RefBlockHash string `json:"ref_block_hash"` + Expiration int64 `json:"expiration"` + Timestamp int64 `json:"timestamp"` + FeeLimit int64 `json:"fee_limit"` + } `json:"raw_data"` + Signature []string `json:"signature"` + TxID string `json:"txID"` +} + +// CreateMockTronTransaction generates a mock TRON transaction for testing +func createMockTronTransaction(ownerAddress, toAddress string, amount int64) MockTronTransaction { + return MockTronTransaction{ + RawData: struct { + Contract []struct { + Parameter struct { + Value struct { + Amount int64 `json:"amount"` + OwnerAddress string `json:"owner_address"` + ToAddress string `json:"to_address"` + } `json:"value"` + TypeURL string `json:"type_url"` + } `json:"parameter"` + Type string `json:"type"` + } `json:"contract"` + RefBlockBytes string `json:"ref_block_bytes"` + RefBlockHash string `json:"ref_block_hash"` + Expiration int64 `json:"expiration"` + Timestamp int64 `json:"timestamp"` + FeeLimit int64 `json:"fee_limit"` + }{ + Contract: []struct { + Parameter struct { + Value struct { + Amount int64 `json:"amount"` + OwnerAddress string `json:"owner_address"` + ToAddress string `json:"to_address"` + } `json:"value"` + TypeURL string `json:"type_url"` + } `json:"parameter"` + Type string `json:"type"` + }{ + { + Parameter: struct { + Value struct { + Amount int64 `json:"amount"` + OwnerAddress string `json:"owner_address"` + ToAddress string `json:"to_address"` + } `json:"value"` + TypeURL string `json:"type_url"` + }{ + Value: struct { + Amount int64 `json:"amount"` + OwnerAddress string `json:"owner_address"` + ToAddress string `json:"to_address"` + }{ + Amount: amount, + OwnerAddress: ownerAddress, + ToAddress: toAddress, + }, + TypeURL: "type.googleapis.com/protocol.TransferContract", + }, + Type: "TransferContract", + }, + }, + RefBlockBytes: "1234", + RefBlockHash: "abcdef0123456789", + Expiration: time.Now().Unix() + 60*60, + Timestamp: time.Now().Unix(), + FeeLimit: 10000000, + }, + } +} + +func serializeMockTransaction(tx MockTronTransaction) ([]byte, error) { + return json.Marshal(tx) +} diff --git a/core/services/relay/relay.go b/core/services/relay/relay.go index 0b1293c8d79..8a6a12e30e3 100644 --- a/core/services/relay/relay.go +++ b/core/services/relay/relay.go @@ -14,6 +14,7 @@ const ( NetworkSolana = "solana" NetworkStarkNet = "starknet" NetworkAptos = "aptos" + NetworkTron = "tron" NetworkDummy = "dummy" ) @@ -24,6 +25,7 @@ var SupportedNetworks = map[string]struct{}{ NetworkSolana: {}, NetworkStarkNet: {}, NetworkAptos: {}, + NetworkTron: {}, NetworkDummy: {}, } diff --git a/core/web/auth/auth_test.go b/core/web/auth/auth_test.go index 25479409545..df869a8b1a3 100644 --- a/core/web/auth/auth_test.go +++ b/core/web/auth/auth_test.go @@ -276,14 +276,17 @@ var routesRolesMap = [...]routeRules{ {"GET", "/v2/keys/cosmos", true, true, true}, {"GET", "/v2/keys/starknet", true, true, true}, {"GET", "/v2/keys/aptos", true, true, true}, + {"GET", "/v2/keys/tron", 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/tron", 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/tron/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}, @@ -292,6 +295,7 @@ var routesRolesMap = [...]routeRules{ {"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/tron/export/MOCK", false, false, false}, {"GET", "/v2/keys/vrf", true, true, true}, {"POST", "/v2/keys/vrf", false, false, true}, {"DELETE", "/v2/keys/vrf/MOCK", false, false, false}, diff --git a/core/web/presenters/node_test.go b/core/web/presenters/node_test.go index d2db83009d9..9f980e22484 100644 --- a/core/web/presenters/node_test.go +++ b/core/web/presenters/node_test.go @@ -15,7 +15,7 @@ func TestNodeResource(t *testing.T) { var nodeResource NodeResource state := "test" cfg := "cfg" - testCases := []string{"solana", "cosmos", "starknet"} + testCases := []string{"solana", "cosmos", "starknet", "tron"} for _, tc := range testCases { chainID := fmt.Sprintf("%s chain ID", tc) nodeName := fmt.Sprintf("%s_node", tc) diff --git a/core/web/presenters/tron_chain.go b/core/web/presenters/tron_chain.go new file mode 100644 index 00000000000..7ab6109bd39 --- /dev/null +++ b/core/web/presenters/tron_chain.go @@ -0,0 +1,45 @@ +package presenters + +import ( + "github.com/smartcontractkit/chainlink-common/pkg/types" +) + +// TronChainResource is an Tron chain JSONAPI resource. +type TronChainResource struct { + ChainResource +} + +// GetName implements the api2go EntityNamer interface +func (r TronChainResource) GetName() string { + return "tron_chain" +} + +// NewTronChainResource returns a new TronChainResource for chain. +func NewTronChainResource(chain types.ChainStatus) TronChainResource { + return TronChainResource{ChainResource{ + JAID: NewJAID(chain.ID), + Config: chain.Config, + Enabled: chain.Enabled, + }} +} + +// TronNodeResource is a Tron node JSONAPI resource. +type TronNodeResource struct { + NodeResource +} + +// GetName implements the api2go EntityNamer interface +func (r TronNodeResource) GetName() string { + return "tron_node" +} + +// NewTronNodeResource returns a new TronNodeResource for node. +func NewTronNodeResource(node types.NodeStatus) TronNodeResource { + return TronNodeResource{NodeResource{ + JAID: NewPrefixedJAID(node.Name, node.ChainID), + ChainID: node.ChainID, + Name: node.Name, + State: node.State, + Config: node.Config, + }} +} diff --git a/core/web/presenters/tron_key.go b/core/web/presenters/tron_key.go new file mode 100644 index 00000000000..abe74ed7f41 --- /dev/null +++ b/core/web/presenters/tron_key.go @@ -0,0 +1,34 @@ +package presenters + +import ( + "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/tronkey" +) + +// TronKeyResource represents a Tron key JSONAPI resource. +type TronKeyResource struct { + JAID + PubKey string `json:"publicKey"` +} + +// GetName implements the api2go EntityNamer interface +func (TronKeyResource) GetName() string { + return "encryptedTronKeys" +} + +func NewTronKeyResource(key tronkey.Key) *TronKeyResource { + r := &TronKeyResource{ + JAID: JAID{ID: key.ID()}, + PubKey: key.PublicKeyStr(), + } + + return r +} + +func NewTronKeyResources(keys []tronkey.Key) []TronKeyResource { + rs := []TronKeyResource{} + for _, key := range keys { + rs = append(rs, *NewTronKeyResource(key)) + } + + return rs +} diff --git a/core/web/resolver/ocr2_keys_test.go b/core/web/resolver/ocr2_keys_test.go index 033d22799b1..e131aa0b5f5 100644 --- a/core/web/resolver/ocr2_keys_test.go +++ b/core/web/resolver/ocr2_keys_test.go @@ -42,6 +42,7 @@ func TestResolver_GetOCR2KeyBundles(t *testing.T) { ocr2key.MustNewInsecure(keystest.NewRandReaderFromSeed(1), "solana"), ocr2key.MustNewInsecure(keystest.NewRandReaderFromSeed(1), "starknet"), ocr2key.MustNewInsecure(keystest.NewRandReaderFromSeed(1), "aptos"), + ocr2key.MustNewInsecure(keystest.NewRandReaderFromSeed(1), "tron"), } expectedBundles := []map[string]interface{}{} for _, k := range fakeKeys { diff --git a/core/web/resolver/resolver_test.go b/core/web/resolver/resolver_test.go index 0d365b0891e..6ff9d954b37 100644 --- a/core/web/resolver/resolver_test.go +++ b/core/web/resolver/resolver_test.go @@ -54,6 +54,7 @@ type mocks struct { aptos *keystoreMocks.Aptos cosmos *keystoreMocks.Cosmos starknet *keystoreMocks.StarkNet + tron *keystoreMocks.Tron chain *legacyEvmORMMocks.Chain legacyEVMChains *legacyEvmORMMocks.LegacyChainContainer relayerChainInterops *chainlinkMocks.FakeRelayerChainInteroperators @@ -112,6 +113,7 @@ func setupFramework(t *testing.T) *gqlTestFramework { aptos: keystoreMocks.NewAptos(t), cosmos: keystoreMocks.NewCosmos(t), starknet: keystoreMocks.NewStarkNet(t), + tron: keystoreMocks.NewTron(t), chain: legacyEvmORMMocks.NewChain(t), legacyEVMChains: legacyEvmORMMocks.NewLegacyChainContainer(t), relayerChainInterops: &chainlinkMocks.FakeRelayerChainInteroperators{}, diff --git a/core/web/resolver/tron_key.go b/core/web/resolver/tron_key.go new file mode 100644 index 00000000000..595749e2f9a --- /dev/null +++ b/core/web/resolver/tron_key.go @@ -0,0 +1,43 @@ +package resolver + +import ( + "github.com/graph-gophers/graphql-go" + + "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/tronkey" +) + +type TronKeyResolver struct { + key tronkey.Key +} + +func NewTronKey(key tronkey.Key) *TronKeyResolver { + return &TronKeyResolver{key: key} +} + +func NewTronKeys(keys []tronkey.Key) []*TronKeyResolver { + var resolvers []*TronKeyResolver + + for _, k := range keys { + resolvers = append(resolvers, NewTronKey(k)) + } + + return resolvers +} + +func (r *TronKeyResolver) ID() graphql.ID { + return graphql.ID(r.key.ID()) +} + +// -- GetTronKeys Query -- + +type TronKeysPayloadResolver struct { + keys []tronkey.Key +} + +func NewTronKeysPayload(keys []tronkey.Key) *TronKeysPayloadResolver { + return &TronKeysPayloadResolver{keys: keys} +} + +func (r *TronKeysPayloadResolver) Results() []*TronKeyResolver { + return NewTronKeys(r.keys) +} diff --git a/core/web/resolver/tron_key_test.go b/core/web/resolver/tron_key_test.go new file mode 100644 index 00000000000..6ccbeb1072d --- /dev/null +++ b/core/web/resolver/tron_key_test.go @@ -0,0 +1,74 @@ +package resolver + +import ( + "context" + "errors" + "fmt" + "testing" + + gqlerrors "github.com/graph-gophers/graphql-go/errors" + + "github.com/smartcontractkit/chainlink/v2/core/internal/testutils/keystest" + "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/tronkey" +) + +func TestResolver_TronKeys(t *testing.T) { + t.Parallel() + + query := ` + query GetTronKeys { + tronKeys { + results { + id + } + } + }` + k := tronkey.MustNewInsecure(keystest.NewRandReaderFromSeed(1)) + result := fmt.Sprintf(` + { + "tronKeys": { + "results": [ + { + "id": "%s" + } + ] + } + }`, k.ID()) + gError := errors.New("error") + + testCases := []GQLTestCase{ + unauthorizedTestCase(GQLTestCase{query: query}, "tronKeys"), + { + name: "success", + authenticated: true, + before: func(ctx context.Context, f *gqlTestFramework) { + f.Mocks.tron.On("GetAll").Return([]tronkey.Key{k}, nil) + f.Mocks.keystore.On("Tron").Return(f.Mocks.tron) + f.App.On("GetKeyStore").Return(f.Mocks.keystore) + }, + query: query, + result: result, + }, + { + name: "no keys returned by GetAll", + authenticated: true, + before: func(ctx context.Context, f *gqlTestFramework) { + f.Mocks.tron.On("GetAll").Return([]tronkey.Key{}, gError) + f.Mocks.keystore.On("Tron").Return(f.Mocks.tron) + f.App.On("GetKeyStore").Return(f.Mocks.keystore) + }, + query: query, + result: `null`, + errors: []*gqlerrors.QueryError{ + { + Extensions: nil, + ResolverError: gError, + Path: []interface{}{"tronKeys"}, + Message: gError.Error(), + }, + }, + }, + } + + RunGQLTests(t, testCases) +} diff --git a/core/web/router.go b/core/web/router.go index c57bf3c8095..f56d3e69651 100644 --- a/core/web/router.go +++ b/core/web/router.go @@ -351,6 +351,7 @@ func v2Routes(app chainlink.Application, r *gin.RouterGroup) { {"cosmos", NewCosmosKeysController(app)}, {"starknet", NewStarkNetKeysController(app)}, {"aptos", NewAptosKeysController(app)}, + {"tron", NewTronKeysController(app)}, } { authv2.GET("/keys/"+keys.path, keys.kc.Index) authv2.POST("/keys/"+keys.path, auth.RequiresEditRole(keys.kc.Create)) diff --git a/core/web/schema/schema.graphql b/core/web/schema/schema.graphql index 568716f7b76..6f9e51b79eb 100644 --- a/core/web/schema/schema.graphql +++ b/core/web/schema/schema.graphql @@ -36,6 +36,7 @@ type Query { aptosKeys: AptosKeysPayload! cosmosKeys: CosmosKeysPayload! starknetKeys: StarkNetKeysPayload! + tronKeys: TronKeysPayload! sqlLogging: GetSQLLoggingPayload! vrfKey(id: ID!): VRFKeyPayload! vrfKeys: VRFKeysPayload! diff --git a/core/web/schema/type/ocr2_keys.graphql b/core/web/schema/type/ocr2_keys.graphql index c25148c686a..89125d86b54 100644 --- a/core/web/schema/type/ocr2_keys.graphql +++ b/core/web/schema/type/ocr2_keys.graphql @@ -4,6 +4,7 @@ enum OCR2ChainType { SOLANA STARKNET APTOS + TRON } type OCR2KeyBundle { diff --git a/core/web/schema/type/tron_key.graphql b/core/web/schema/type/tron_key.graphql new file mode 100644 index 00000000000..e7319f08d6b --- /dev/null +++ b/core/web/schema/type/tron_key.graphql @@ -0,0 +1,7 @@ +type TronKey { + id: ID! +} + +type TronKeysPayload { + results: [TronKey!]! +} diff --git a/core/web/tron_keys_controller.go b/core/web/tron_keys_controller.go new file mode 100644 index 00000000000..e9ac2e0252e --- /dev/null +++ b/core/web/tron_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/tronkey" + "github.com/smartcontractkit/chainlink/v2/core/web/presenters" +) + +func NewTronKeysController(app chainlink.Application) KeysController { + return NewKeysController[tronkey.Key, presenters.TronKeyResource](app.GetKeyStore().Tron(), app.GetLogger(), app.GetAuditLogger(), + "tronKey", presenters.NewTronKeyResource, presenters.NewTronKeyResources) +} diff --git a/core/web/tron_keys_controller_test.go b/core/web/tron_keys_controller_test.go new file mode 100644 index 00000000000..5628d7ac2dc --- /dev/null +++ b/core/web/tron_keys_controller_test.go @@ -0,0 +1,105 @@ +package web_test + +import ( + "net/http" + "testing" + + "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" + + "github.com/stretchr/testify/require" +) + +func TestTronKeysController_Index_HappyPath(t *testing.T) { + t.Parallel() + + client, keyStore := setupTronKeysControllerTests(t) + keys, _ := keyStore.Tron().GetAll() + + response, cleanup := client.Get("/v2/keys/tron") + t.Cleanup(cleanup) + cltest.AssertServerResponse(t, response, http.StatusOK) + + resources := []presenters.TronKeyResource{} + err := web.ParseJSONAPIResponse(cltest.ParseResponseBody(t, response), &resources) + require.NoError(t, err) + + require.Len(t, resources, len(keys)) + + require.Equal(t, keys[0].ID(), resources[0].ID) + require.Equal(t, keys[0].PublicKeyStr(), resources[0].PubKey) +} + +func TestTronKeysController_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/tron", nil) + t.Cleanup(cleanup) + cltest.AssertServerResponse(t, response, http.StatusOK) + + keys, _ := keyStore.Tron().GetAll() + require.Len(t, keys, 1) + + resource := presenters.TronKeyResource{} + err := web.ParseJSONAPIResponse(cltest.ParseResponseBody(t, response), &resource) + require.NoError(t, err) + + require.Equal(t, keys[0].ID(), resource.ID) + require.Equal(t, keys[0].PublicKeyStr(), resource.PubKey) + + _, err = keyStore.Tron().Get(resource.ID) + require.NoError(t, err) +} + +func TestTronKeysController_Delete_NonExistentTronKeyID(t *testing.T) { + t.Parallel() + + client, _ := setupTronKeysControllerTests(t) + + nonExistentTronKeyID := "foobar" + response, cleanup := client.Delete("/v2/keys/tron/" + nonExistentTronKeyID) + t.Cleanup(cleanup) + require.Equal(t, http.StatusNotFound, response.StatusCode) +} + +func TestTronKeysController_Delete_HappyPath(t *testing.T) { + t.Parallel() + ctx := testutils.Context(t) + + client, keyStore := setupTronKeysControllerTests(t) + + keys, _ := keyStore.Tron().GetAll() + initialLength := len(keys) + key, _ := keyStore.Tron().Create(ctx) + + response, cleanup := client.Delete("/v2/keys/tron/" + key.ID()) + t.Cleanup(cleanup) + require.Equal(t, http.StatusOK, response.StatusCode) + require.Error(t, utils.JustError(keyStore.Tron().Get(key.ID()))) + + keys, _ = keyStore.Tron().GetAll() + require.Len(t, keys, initialLength) +} + +func setupTronKeysControllerTests(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)) + require.NoError(t, app.KeyStore.Tron().Add(ctx, cltest.DefaultTronKey)) + + client := app.NewHTTPClient(nil) + + return client, app.GetKeyStore() +} diff --git a/deployment/environment/nodeclient/chainlink.go b/deployment/environment/nodeclient/chainlink.go index 9b92dd12759..749656836c6 100644 --- a/deployment/environment/nodeclient/chainlink.go +++ b/deployment/environment/nodeclient/chainlink.go @@ -1005,6 +1005,34 @@ func (c *ChainlinkClient) CreateStarkNetNode(node *StarkNetNodeAttributes) (*Sta return &response, resp.RawResponse, err } +// CreateTronChain creates a tron chain +func (c *ChainlinkClient) CreateTronChain(chain *TronChainAttributes) (*TronChainCreate, *http.Response, error) { + response := TronChainCreate{} + c.l.Info().Str(NodeURL, c.Config.URL).Str("Chain ID", chain.ChainID).Msg("Creating Tron Chain") + resp, err := c.APIClient.R(). + SetBody(chain). + SetResult(&response). + Post("/v2/chains/tron") + if err != nil { + return nil, nil, err + } + return &response, resp.RawResponse, err +} + +// CreateTronNode creates a tron node +func (c *ChainlinkClient) CreateTronNode(node *TronNodeAttributes) (*TronNodeCreate, *http.Response, error) { + response := TronNodeCreate{} + c.l.Info().Str(NodeURL, c.Config.URL).Str("Name", node.Name).Msg("Creating Tron Node") + resp, err := c.APIClient.R(). + SetBody(node). + SetResult(&response). + Post("/v2/nodes/tron") + if err != nil { + return nil, nil, err + } + return &response, resp.RawResponse, err +} + // InternalIP retrieves the inter-cluster IP of the Chainlink node, for use with inter-node communications func (c *ChainlinkClient) InternalIP() string { return c.Config.InternalIP diff --git a/deployment/environment/nodeclient/chainlink_models.go b/deployment/environment/nodeclient/chainlink_models.go index 84bea9cec31..1f4bbe4ebd0 100644 --- a/deployment/environment/nodeclient/chainlink_models.go +++ b/deployment/environment/nodeclient/chainlink_models.go @@ -520,6 +520,48 @@ type StarkNetNodeCreate struct { Data StarkNetNode `json:"data"` } +type TronChainConfig struct { + OCR2CachePollPeriod null.String + OCR2CacheTTL null.String + RequestTimeout null.String + TxTimeout null.Bool + TxSendFrequency null.String + TxMaxBatchSize null.String +} + +// TronChainAttributes is the model that represents the tron chain +type TronChainAttributes struct { + ChainID string `json:"chainID"` + Config TronChainConfig `json:"config"` +} + +// TronChain is the model that represents the tron chain when read +type TronChain struct { + Attributes TronChainAttributes `json:"attributes"` +} + +// TronChainCreate is the model that represents the tron chain when created +type TronChainCreate struct { + Data TronChain `json:"data"` +} + +// TronNodeAttributes is the model that represents the tron node +type TronNodeAttributes struct { + Name string `json:"name"` + ChainID string `json:"chainId"` + URL string `json:"url"` +} + +// TronNode is the model that represents the tron node when read +type TronNode struct { + Attributes TronNodeAttributes `json:"attributes"` +} + +// TronNodeCreate is the model that represents the tron node when created +type TronNodeCreate struct { + Data TronNode `json:"data"` +} + // SpecForm is the form used when creating a v2 job spec, containing the TOML of the v2 job type SpecForm struct { TOML string `json:"toml"` diff --git a/testdata/scripts/chains/help.txtar b/testdata/scripts/chains/help.txtar index ccfb54d2928..5d9a8945ad9 100644 --- a/testdata/scripts/chains/help.txtar +++ b/testdata/scripts/chains/help.txtar @@ -14,6 +14,7 @@ COMMANDS: evm Commands for handling evm chains solana Commands for handling solana chains starknet Commands for handling starknet chains + tron Commands for handling tron chains OPTIONS: --help, -h show help diff --git a/testdata/scripts/chains/tron/help.txtar b/testdata/scripts/chains/tron/help.txtar new file mode 100644 index 00000000000..b0af73f1a25 --- /dev/null +++ b/testdata/scripts/chains/tron/help.txtar @@ -0,0 +1,16 @@ +exec chainlink chains tron --help +cmp stdout out.txt + +-- out.txt -- +NAME: + chainlink chains tron - Commands for handling tron chains + +USAGE: + chainlink chains tron command [command options] [arguments...] + +COMMANDS: + list List all existing tron chains + +OPTIONS: + --help, -h show help + diff --git a/testdata/scripts/chains/tron/list/help.txtar b/testdata/scripts/chains/tron/list/help.txtar new file mode 100644 index 00000000000..ce18cf3de6d --- /dev/null +++ b/testdata/scripts/chains/tron/list/help.txtar @@ -0,0 +1,9 @@ +exec chainlink chains tron list --help +cmp stdout out.txt + +-- out.txt -- +NAME: + chainlink chains tron list - List all existing tron chains + +USAGE: + chainlink chains tron list [arguments...] diff --git a/testdata/scripts/help-all/help-all.txtar b/testdata/scripts/help-all/help-all.txtar index 078853ef6a5..87d715edcba 100644 --- a/testdata/scripts/help-all/help-all.txtar +++ b/testdata/scripts/help-all/help-all.txtar @@ -34,6 +34,8 @@ chains solana # Commands for handling solana chains chains solana list # List all existing solana chains chains starknet # Commands for handling starknet chains chains starknet list # List all existing starknet chains +chains tron # Commands for handling tron chains +chains tron list # List all existing tron chains config # Commands for the node's configuration config loglevel # Set log level config logsql # Enable/disable SQL statement logging @@ -111,6 +113,12 @@ keys starknet delete # Delete StarkNet key if present keys starknet export # Export StarkNet key to keyfile keys starknet import # Import StarkNet key from keyfile keys starknet list # List the StarkNet keys +keys tron # Remote commands for administering the node's Tron keys +keys tron create # Create a Tron key +keys tron delete # Delete Tron key if present +keys tron export # Export Tron key to keyfile +keys tron import # Import Tron key from keyfile +keys tron list # List the Tron keys keys vrf # Remote commands for administering the node's vrf keys keys vrf create # Create a VRF key keys vrf delete # Archive or delete VRF key from memory and the database, if present. Note that jobs referencing the removed key will also be removed. @@ -144,6 +152,8 @@ nodes solana # Commands for handling solana node configuration nodes solana list # List all existing solana nodes nodes starknet # Commands for handling starknet node configuration nodes starknet list # List all existing starknet nodes +nodes tron # Commands for handling tron node configuration +nodes tron list # List all existing tron nodes txs # Commands for handling transactions txs cosmos # Commands for handling Cosmos transactions txs cosmos create # Send of from node Cosmos account to destination . diff --git a/testdata/scripts/keys/help.txtar b/testdata/scripts/keys/help.txtar index 83253d6906d..e930b928f54 100644 --- a/testdata/scripts/keys/help.txtar +++ b/testdata/scripts/keys/help.txtar @@ -18,6 +18,7 @@ COMMANDS: 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 + tron Remote commands for administering the node's Tron keys vrf Remote commands for administering the node's vrf keys OPTIONS: diff --git a/testdata/scripts/keys/tron/help.txtar b/testdata/scripts/keys/tron/help.txtar new file mode 100644 index 00000000000..6e0b8bf31a2 --- /dev/null +++ b/testdata/scripts/keys/tron/help.txtar @@ -0,0 +1,20 @@ +exec chainlink keys tron --help +cmp stdout out.txt + +-- out.txt -- +NAME: + chainlink keys tron - Remote commands for administering the node's Tron keys + +USAGE: + chainlink keys tron command [command options] [arguments...] + +COMMANDS: + create Create a Tron key + import Import Tron key from keyfile + export Export Tron key to keyfile + delete Delete Tron key if present + list List the Tron keys + +OPTIONS: + --help, -h show help + diff --git a/testdata/scripts/node/validate/invalid-duplicates.txtar b/testdata/scripts/node/validate/invalid-duplicates.txtar index 84e6c23aa71..d13fff5f620 100644 --- a/testdata/scripts/node/validate/invalid-duplicates.txtar +++ b/testdata/scripts/node/validate/invalid-duplicates.txtar @@ -63,6 +63,19 @@ URL = 'http://stark.node' Name = 'primary' URL = 'http://stark.node' +[[Tron]] +ChainID = '1' + +[[Tron]] +ChainID = '1' + +[[Tron.Nodes]] +Name = 'fake' +URL = 'https://foo.bar' + +[[Tron.Nodes]] +Name = 'fake' +URL = 'https://foo.bar' -- secrets.toml -- [Database] @@ -74,7 +87,7 @@ Keystore = '' -- out.txt -- -- err.txt -- -Error running app: invalid configuration: 4 errors: +Error running app: invalid configuration: 5 errors: - EVM: 4 errors: - 1.ChainID: invalid value (1): duplicate - must be unique - 1.Nodes.1.Name: invalid value (fake): duplicate - must be unique @@ -92,3 +105,6 @@ Error running app: invalid configuration: 4 errors: - 1.ChainID: invalid value (foobar): duplicate - must be unique - 1.Nodes.1.Name: invalid value (primary): duplicate - must be unique - 1.Nodes.1.URL: invalid value (http://stark.node): duplicate - must be unique + - Tron: 2 errors: + - 1.ChainID: invalid value (1): duplicate - must be unique + - 1.Nodes.1.Name: invalid value (fake): duplicate - must be unique diff --git a/testdata/scripts/nodes/help.txtar b/testdata/scripts/nodes/help.txtar index f9132045d29..c8409d62691 100644 --- a/testdata/scripts/nodes/help.txtar +++ b/testdata/scripts/nodes/help.txtar @@ -14,6 +14,7 @@ COMMANDS: evm Commands for handling evm node configuration solana Commands for handling solana node configuration starknet Commands for handling starknet node configuration + tron Commands for handling tron node configuration OPTIONS: --help, -h show help diff --git a/testdata/scripts/nodes/tron/help.txtar b/testdata/scripts/nodes/tron/help.txtar new file mode 100644 index 00000000000..e35e174e6d8 --- /dev/null +++ b/testdata/scripts/nodes/tron/help.txtar @@ -0,0 +1,16 @@ +exec chainlink nodes tron --help +cmp stdout out.txt + +-- out.txt -- +NAME: + chainlink nodes tron - Commands for handling tron node configuration + +USAGE: + chainlink nodes tron command [command options] [arguments...] + +COMMANDS: + list List all existing tron nodes + +OPTIONS: + --help, -h show help + diff --git a/testdata/scripts/nodes/tron/list/help.txtar b/testdata/scripts/nodes/tron/list/help.txtar new file mode 100644 index 00000000000..08c9d07d56b --- /dev/null +++ b/testdata/scripts/nodes/tron/list/help.txtar @@ -0,0 +1,9 @@ +exec chainlink nodes tron list --help +cmp stdout out.txt + +-- out.txt -- +NAME: + chainlink nodes tron list - List all existing tron nodes + +USAGE: + chainlink nodes tron list [arguments...]