diff --git a/.changeset/twelve-socks-drop.md b/.changeset/twelve-socks-drop.md new file mode 100644 index 00000000000..f1df685ed9b --- /dev/null +++ b/.changeset/twelve-socks-drop.md @@ -0,0 +1,5 @@ +--- +"chainlink": patch +--- + +Add Sui keystore and relayer plugin basic integration diff --git a/core/capabilities/ccip/ccipaptos/pluginconfig.go b/core/capabilities/ccip/ccipaptos/pluginconfig.go index 64d4711094d..760bd5df0bc 100644 --- a/core/capabilities/ccip/ccipaptos/pluginconfig.go +++ b/core/capabilities/ccip/ccipaptos/pluginconfig.go @@ -5,25 +5,37 @@ import ( ccipcommon "github.com/smartcontractkit/chainlink/v2/core/capabilities/ccip/common" "github.com/smartcontractkit/chainlink/v2/core/capabilities/ccip/ocrimpls" "github.com/smartcontractkit/chainlink/v2/core/logger" + + "github.com/smartcontractkit/chainlink/v2/core/capabilities/ccip/ccipsui" ) // initializePluginConfig returns a PluginConfig for Aptos chains. -func initializePluginConfig(lggr logger.Logger, extraDataCodec ccipcommon.ExtraDataCodec) ccipcommon.PluginConfig { - return ccipcommon.PluginConfig{ - CommitPluginCodec: NewCommitPluginCodecV1(), - ExecutePluginCodec: NewExecutePluginCodecV1(extraDataCodec), - MessageHasher: NewMessageHasherV1(lggr.Named(chainsel.FamilyAptos).Named("MessageHasherV1"), extraDataCodec), - TokenDataEncoder: NewAptosTokenDataEncoder(), - GasEstimateProvider: NewGasEstimateProvider(), - RMNCrypto: nil, - ContractTransmitterFactory: ocrimpls.NewAptosContractTransmitterFactory(extraDataCodec), - ChainRW: ChainCWProvider{}, - ExtraDataCodec: ExtraDataDecoder{}, - AddressCodec: AddressCodec{}, +func initializePluginConfigFunc(chainselFamily string) ccipcommon.InitFunction { + return func(lggr logger.Logger, extraDataCodec ccipcommon.ExtraDataCodec) ccipcommon.PluginConfig { + var cwProvider ccipcommon.ChainRWProvider + if chainselFamily == chainsel.FamilyAptos { + cwProvider = ChainCWProvider{} + } else { + cwProvider = ccipsui.ChainCWProvider{} + } + + return ccipcommon.PluginConfig{ + CommitPluginCodec: NewCommitPluginCodecV1(), + ExecutePluginCodec: NewExecutePluginCodecV1(extraDataCodec), + MessageHasher: NewMessageHasherV1(lggr.Named(chainselFamily).Named("MessageHasherV1"), extraDataCodec), + TokenDataEncoder: NewAptosTokenDataEncoder(), + GasEstimateProvider: NewGasEstimateProvider(), + RMNCrypto: nil, + ContractTransmitterFactory: ocrimpls.NewAptosContractTransmitterFactory(extraDataCodec), + ChainRW: cwProvider, + ExtraDataCodec: ExtraDataDecoder{}, + AddressCodec: AddressCodec{}, + } } } func init() { - // Register the Aptos plugin config factory - ccipcommon.RegisterPluginConfig(chainsel.FamilyAptos, initializePluginConfig) + // Register the Aptos and Sui plugin config factory + ccipcommon.RegisterPluginConfig(chainsel.FamilyAptos, initializePluginConfigFunc(chainsel.FamilyAptos)) + ccipcommon.RegisterPluginConfig(chainsel.FamilySui, initializePluginConfigFunc(chainsel.FamilySui)) } diff --git a/core/capabilities/ccip/ccipsui/crcwconfig.go b/core/capabilities/ccip/ccipsui/crcwconfig.go new file mode 100644 index 00000000000..622a7c52dba --- /dev/null +++ b/core/capabilities/ccip/ccipsui/crcwconfig.go @@ -0,0 +1,52 @@ +package ccipsui + +import ( + "context" + "encoding/json" + "fmt" + + aptosloop "github.com/smartcontractkit/chainlink-aptos/relayer/chainreader/loop" + "github.com/smartcontractkit/chainlink-common/pkg/types" + ccipcommon "github.com/smartcontractkit/chainlink/v2/core/capabilities/ccip/common" + suiconfig "github.com/smartcontractkit/chainlink/v2/core/capabilities/ccip/configs/sui" +) + +// ChainCWProvider is a struct that implements the ChainRWProvider interface for EVM chains. +type ChainCWProvider struct{} + +// GetChainReader returns a new ContractReader for EVM chains. +func (g ChainCWProvider) GetChainReader(ctx context.Context, params ccipcommon.ChainReaderProviderOpts) (types.ContractReader, error) { + marshaledConfig, err := json.Marshal(suiconfig.ChainReaderConfig) + if err != nil { + return nil, fmt.Errorf("failed to marshal Aptos chain reader config: %w", err) + } + + cr, err := params.Relayer.NewContractReader(ctx, marshaledConfig) + if err != nil { + return nil, err + } + + cr = aptosloop.NewLoopChainReader(params.Lggr, cr) + + return cr, nil +} + +// GetChainWriter returns a new ContractWriter for EVM chains. +func (g ChainCWProvider) GetChainWriter(ctx context.Context, params ccipcommon.ChainWriterProviderOpts) (types.ContractWriter, error) { + transmitter := params.Transmitters[types.NewRelayID(params.ChainFamily, params.ChainID)] + cfg, err := suiconfig.GetChainWriterConfig(transmitter[0]) + if err != nil { + return nil, fmt.Errorf("failed to get Aptos chain writer config: %w", err) + } + chainWriterConfig, err := json.Marshal(cfg) + if err != nil { + return nil, fmt.Errorf("failed to marshal Aptos chain writer config: %w", err) + } + + cw, err := params.Relayer.NewContractWriter(ctx, chainWriterConfig) + if err != nil { + return nil, fmt.Errorf("failed to create chain writer for chain %s: %w", params.ChainID, err) + } + + return cw, nil +} diff --git a/core/capabilities/ccip/configs/sui/chain_writer.go b/core/capabilities/ccip/configs/sui/chain_writer.go new file mode 100644 index 00000000000..7ebcdf454c9 --- /dev/null +++ b/core/capabilities/ccip/configs/sui/chain_writer.go @@ -0,0 +1,67 @@ +package suiconfig + +import ( + "fmt" + + "github.com/smartcontractkit/chainlink-aptos/relayer/utils" + "github.com/smartcontractkit/chainlink-ccip/pkg/consts" +) + +func GetChainWriterConfig(publicKeyStr string) (map[string]any, error) { + fromAddress, err := utils.HexPublicKeyToAddress(publicKeyStr) + if err != nil { + return map[string]any{}, fmt.Errorf("failed to parse Sui address from public key %s: %w", publicKeyStr, err) + } + + fmt.Printf("DEBUG: Aptos GetChainWriterConfig: fromAddressStr=%s, pubKeyStr=%s\n", fromAddress.String(), publicKeyStr) + + return map[string]any{ + "modules": map[string]any{ + consts.ContractNameOffRamp: map[string]any{ + "name": "offramp", + "functions": map[string]any{ + consts.MethodCommit: map[string]any{ + "name": "commit", + "public_key": publicKeyStr, + "from_address": fromAddress.String(), + "params": []map[string]any{ + { + "name": "ReportContext", + "type": "vector>", + "required": true, + }, + { + "name": "Report", + "type": "vector", + "required": true, + }, + { + "name": "Signatures", + "type": "vector>", + "required": true, + }, + }, + }, + consts.MethodExecute: map[string]any{ + "name": "execute", + "public_key": publicKeyStr, + "from_address": fromAddress.String(), + "params": []map[string]any{ + { + "name": "ReportContext", + "type": "vector>", + "required": true, + }, + { + "name": "Report", + "type": "vector", + "required": true, + }, + }, + }, + }, + }, + }, + "fee_strategy": "default", // Assuming chainwriter.DefaultFeeStrategy is a string constant + }, nil +} diff --git a/core/capabilities/ccip/configs/sui/contract_reader.go b/core/capabilities/ccip/configs/sui/contract_reader.go new file mode 100644 index 00000000000..e308147f694 --- /dev/null +++ b/core/capabilities/ccip/configs/sui/contract_reader.go @@ -0,0 +1,354 @@ +package suiconfig + +import ( + "github.com/smartcontractkit/chainlink-ccip/pkg/consts" +) + +var ChainReaderConfig = map[string]any{ + "IsLoopPlugin": true, + "Modules": map[string]any{ + // TODO: more offramp config and other modules + consts.ContractNameRMNRemote: map[string]any{ + "Name": "rmn_remote", + "Functions": map[string]any{ + consts.MethodNameGetReportDigestHeader: map[string]any{ + "Name": "get_report_digest_header", + }, + consts.MethodNameGetVersionedConfig: map[string]any{ + "Name": "get_versioned_config", + // ref: https://github.com/smartcontractkit/chainlink-ccip/blob/bee7c32c71cf0aec594c051fef328b4a7281a1fc/pkg/reader/ccip.go#L1440 + "ResultTupleToStruct": []string{"version", "config"}, + }, + consts.MethodNameGetCursedSubjects: map[string]any{ + "Name": "get_cursed_subjects", + }, + }, + }, + consts.ContractNameRMNProxy: map[string]any{ + "Name": "rmn_remote", + "Functions": map[string]any{ + consts.MethodNameGetARM: map[string]any{ + "Name": "get_arm", + }, + }, + }, + consts.ContractNameFeeQuoter: map[string]any{ + "Name": "fee_quoter", + "Functions": map[string]any{ + consts.MethodNameFeeQuoterGetTokenPrice: map[string]any{ + "Name": "get_token_price", + "Params": []map[string]any{ + { + "Name": "token", + "Type": "address", + "Required": true, + }, + }, + }, + consts.MethodNameFeeQuoterGetTokenPrices: map[string]any{ + "Name": "get_token_prices", + "Params": []map[string]any{ + { + "Name": "tokens", + "Type": "vector
", + "Required": true, + }, + }, + }, + consts.MethodNameFeeQuoterGetStaticConfig: map[string]any{ + "Name": "get_static_config", + }, + consts.MethodNameGetFeePriceUpdate: map[string]any{ + "Name": "get_dest_chain_gas_price", + "Params": []map[string]any{ + { + "Name": "destChainSelector", + "Type": "u64", + "Required": true, + }, + }, + }, + }, + }, + consts.ContractNameOffRamp: map[string]any{ + "Name": "offramp", + "Functions": map[string]any{ + consts.MethodNameGetExecutionState: map[string]any{ + "Name": "get_execution_state", + "Params": []map[string]any{ + { + "Name": "sourceChainSelector", + "Type": "u64", + "Required": true, + }, + { + "Name": "sequenceNumber", + "Type": "u64", + "Required": true, + }, + }, + }, + consts.MethodNameGetMerkleRoot: map[string]any{ + "Name": "get_merkle_root", + "Params": []map[string]any{ + { + "Name": "root", + "Type": "vector", + "Required": true, + }, + }, + }, + consts.MethodNameOffRampLatestConfigDetails: map[string]any{ + "Name": "latest_config_details", + "Params": []map[string]any{ + { + "Name": "ocrPluginType", + "Type": "u8", + "Required": true, + }, + }, + // wrap the returned OCR config + // https://github.com/smartcontractkit/chainlink-ccip/blob/bee7c32c71cf0aec594c051fef328b4a7281a1fc/pkg/reader/ccip.go#L141 + "ResultTupleToStruct": []string{"ocr_config"}, + }, + consts.MethodNameGetLatestPriceSequenceNumber: map[string]any{ + "Name": "get_latest_price_sequence_number", + }, + consts.MethodNameOffRampGetStaticConfig: map[string]any{ + "Name": "get_static_config", + // TODO: field renames + }, + consts.MethodNameOffRampGetDynamicConfig: map[string]any{ + "Name": "get_dynamic_config", + // TODO: field renames + }, + consts.MethodNameGetSourceChainConfig: map[string]any{ + "Name": "get_source_chain_config", + "Params": []map[string]any{ + { + "Name": "sourceChainSelector", + "Type": "u64", + "Required": true, + }, + }, + }, + }, + "Events": map[string]any{ + consts.EventNameExecutionStateChanged: map[string]any{ + "EventHandleStructName": "OffRampState", + "EventHandleFieldName": "execution_state_changed_events", + "EventAccountAddress": "offramp::get_state_address", + "EventFieldRenames": map[string]any{ + "source_chain_selector": map[string]any{ + "NewName": "SourceChainSelector", + }, + "sequence_number": map[string]any{ + "NewName": "SequenceNumber", + }, + "message_id": map[string]any{ + "NewName": "MessageId", + }, + "message_hash": map[string]any{ + "NewName": "MessageHash", + }, + "state": map[string]any{ + "NewName": "State", + }, + }, + }, + consts.EventNameCommitReportAccepted: map[string]any{ + "EventHandleStructName": "OffRampState", + "EventHandleFieldName": "commit_report_accepted_events", + "EventAccountAddress": "offramp::get_state_address", + "EventFieldRenames": map[string]any{ + "blessed_merkle_roots": map[string]any{ + "NewName": "BlessedMerkleRoots", + "SubFieldRenames": map[string]any{ + "source_chain_selector": map[string]any{ + "NewName": "SourceChainSelector", + }, + "on_ramp_address": map[string]any{ + "NewName": "OnRampAddress", + }, + "min_seq_nr": map[string]any{ + "NewName": "MinSeqNr", + }, + "max_seq_nr": map[string]any{ + "NewName": "MaxSeqNr", + }, + "merkle_root": map[string]any{ + "NewName": "MerkleRoot", + }, + }, + }, + "unblessed_merkle_roots": map[string]any{ + "NewName": "UnblessedMerkleRoots", + "SubFieldRenames": map[string]any{ + "source_chain_selector": map[string]any{ + "NewName": "SourceChainSelector", + }, + "on_ramp_address": map[string]any{ + "NewName": "OnRampAddress", + }, + "min_seq_nr": map[string]any{ + "NewName": "MinSeqNr", + }, + "max_seq_nr": map[string]any{ + "NewName": "MaxSeqNr", + }, + "merkle_root": map[string]any{ + "NewName": "MerkleRoot", + }, + }, + }, + "price_updates": map[string]any{ + "NewName": "PriceUpdates", + "SubFieldRenames": map[string]any{ + "token_price_updates": map[string]any{ + "NewName": "TokenPriceUpdates", + "SubFieldRenames": map[string]any{ + "source_token": map[string]any{ + "NewName": "SourceToken", + }, + "usd_per_token": map[string]any{ + "NewName": "UsdPerToken", + }, + }, + }, + "gas_price_updates": map[string]any{ + "NewName": "GasPriceUpdates", + "SubFieldRenames": map[string]any{ + "dest_chain_selector": map[string]any{ + "NewName": "DestChainSelector", + }, + "usd_per_unit_gas": map[string]any{ + "NewName": "UsdPerUnitGas", + }, + }, + }, + }, + }, + }, + }, + }, + }, + consts.ContractNameOnRamp: map[string]any{ + "Name": "onramp", + "Functions": map[string]any{ + consts.MethodNameOnRampGetDynamicConfig: map[string]any{ + "Name": "get_dynamic_config", + }, + consts.MethodNameOnRampGetStaticConfig: map[string]any{ + "Name": "get_static_config", + }, + consts.MethodNameOnRampGetDestChainConfig: map[string]any{ + "Name": "get_dest_chain_config", + "Params": []map[string]any{ + { + "Name": "destChainSelector", + "Type": "u64", + "Required": true, + }, + }, + "ResultTupleToStruct": []string{"sequenceNumber", "allowListEnabled", "router"}, + }, + consts.MethodNameGetExpectedNextSequenceNumber: map[string]any{ + "Name": "get_expected_next_sequence_number", + "Params": []map[string]any{ + { + "Name": "destChainSelector", + "Type": "u64", + "Required": true, + }, + }, + }, + }, + "Events": map[string]any{ + consts.EventNameCCIPMessageSent: map[string]any{ + "EventHandleStructName": "OnRampState", + "EventHandleFieldName": "ccip_message_sent_events", + "EventAccountAddress": "onramp::get_state_address", + "EventFieldRenames": map[string]any{ + "dest_chain_selector": map[string]any{ + "NewName": "DestChainSelector", + "SubFieldRenames": nil, + }, + "sequence_number": map[string]any{ + "NewName": "SequenceNumber", + "SubFieldRenames": nil, + }, + "message": map[string]any{ + "NewName": "Message", + "SubFieldRenames": map[string]any{ + "header": map[string]any{ + "NewName": "Header", + "SubFieldRenames": map[string]any{ + "source_chain_selector": map[string]any{ + "NewName": "SourceChainSelector", + }, + "dest_chain_selector": map[string]any{ + "NewName": "DestChainSelector", + }, + "sequence_number": map[string]any{ + "NewName": "SequenceNumber", + }, + "message_id": map[string]any{ + "NewName": "MessageID", + }, + "nonce": map[string]any{ + "NewName": "Nonce", + }, + }, + }, + "sender": map[string]any{ + "NewName": "Sender", + }, + "data": map[string]any{ + "NewName": "Data", + }, + "receiver": map[string]any{ + "NewName": "Receiver", + }, + "extra_args": map[string]any{ + "NewName": "ExtraArgs", + }, + "fee_token": map[string]any{ + "NewName": "FeeToken", + }, + "fee_token_amount": map[string]any{ + "NewName": "FeeTokenAmount", + }, + "fee_value_juels": map[string]any{ + "NewName": "FeeValueJuels", + }, + "token_amounts": map[string]any{ + "NewName": "TokenAmounts", + "SubFieldRenames": map[string]any{ + "source_pool_address": map[string]any{ + "NewName": "SourcePoolAddress", + }, + "dest_token_address": map[string]any{ + "NewName": "DestTokenAddress", + }, + "extra_data": map[string]any{ + "NewName": "ExtraData", + }, + "amount": map[string]any{ + "NewName": "Amount", + }, + "dest_exec_data": map[string]any{ + "NewName": "DestExecData", + }, + }, + }, + }, + }, + }, + "EventFilterRenames": map[string]string{ + "DestChain": "DestChainSelector", + }, + }, + }, + }, + }, +} diff --git a/core/cmd/app.go b/core/cmd/app.go index bb6211d8bb4..acf1e223141 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("Sui", NewSuiKeysClient(s)), keysCommand("Tron", NewTronKeysClient(s)), initVRFKeysSubCmd(s), diff --git a/core/cmd/sui_key_commands.go b/core/cmd/sui_key_commands.go new file mode 100644 index 00000000000..29b77e86793 --- /dev/null +++ b/core/cmd/sui_key_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/suikey" + "github.com/smartcontractkit/chainlink/v2/core/web/presenters" +) + +type SuiKeyPresenter struct { + JAID + presenters.SuiKeyResource +} + +// RenderTable implements TableRenderer +func (p SuiKeyPresenter) RenderTable(rt RendererTable) error { + headers := []string{"ID", "Sui Public Key"} + rows := [][]string{p.ToRow()} + + if _, err := rt.Write([]byte("🔑 Sui Keys\n")); err != nil { + return err + } + renderList(headers, rows, rt.Writer) + + return utils.JustError(rt.Write([]byte("\n"))) +} + +func (p *SuiKeyPresenter) ToRow() []string { + row := []string{ + p.ID, + p.PubKey, + } + + return row +} + +type SuiKeyPresenters []SuiKeyPresenter + +// RenderTable implements TableRenderer +func (ps SuiKeyPresenters) RenderTable(rt RendererTable) error { + headers := []string{"ID", "Sui Public Key"} + rows := [][]string{} + + for _, p := range ps { + rows = append(rows, p.ToRow()) + } + + if _, err := rt.Write([]byte("🔑 Sui Keys\n")); err != nil { + return err + } + renderList(headers, rows, rt.Writer) + + return utils.JustError(rt.Write([]byte("\n"))) +} + +func NewSuiKeysClient(s *Shell) KeysClient { + return newKeysClient[suikey.Key, SuiKeyPresenter, SuiKeyPresenters]("Sui", s) +} diff --git a/core/cmd/sui_key_commands_test.go b/core/cmd/sui_key_commands_test.go new file mode 100644 index 00000000000..5fbcd94f1ae --- /dev/null +++ b/core/cmd/sui_key_commands_test.go @@ -0,0 +1,174 @@ +package cmd_test + +import ( + "bytes" + "context" + "flag" + "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/suikey" + "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/web/presenters" +) + +func TestSuiKeyPresenter_RenderTable(t *testing.T) { + t.Parallel() + + var ( + id = "1" + pubKey = "somepubkey" + buffer = bytes.NewBufferString("") + r = cmd.RendererTable{Writer: buffer} + ) + + p := cmd.SuiKeyPresenter{ + JAID: cmd.JAID{ID: id}, + SuiKeyResource: presenters.SuiKeyResource{ + 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.SuiKeyPresenters{p} + require.NoError(t, ps.RenderTable(r)) + + output = buffer.String() + assert.Contains(t, output, id) + assert.Contains(t, output, pubKey) +} + +func TestShell_SuiKeys(t *testing.T) { + app := startNewApplicationV2(t, nil) + ks := app.GetKeyStore().Sui() + 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()))) + } + requireSuiKeyCount(t, app, 0) + } + + t.Run("ListSuiKeys", func(tt *testing.T) { + defer cleanup() + ctx := testutils.Context(t) + client, r := app.NewShellAndRenderer() + key, err := app.GetKeyStore().Sui().Create(ctx) + require.NoError(t, err) + requireSuiKeyCount(t, app, 1) + assert.NoError(t, cmd.NewSuiKeysClient(client).ListKeys(cltest.EmptyCLIContext())) + require.Len(t, r.Renders, 1) + keys := *r.Renders[0].(*cmd.SuiKeyPresenters) + assert.Equal(t, key.PublicKeyStr(), keys[0].PubKey) + }) + + t.Run("CreateSuiKey", func(tt *testing.T) { + defer cleanup() + client, _ := app.NewShellAndRenderer() + require.NoError(t, cmd.NewSuiKeysClient(client).CreateKey(nilContext)) + keys, err := app.GetKeyStore().Sui().GetAll() + require.NoError(t, err) + require.Len(t, keys, 1) + }) + + t.Run("DeleteSuiKey", func(tt *testing.T) { + defer cleanup() + ctx := testutils.Context(t) + client, _ := app.NewShellAndRenderer() + key, err := app.GetKeyStore().Sui().Create(ctx) + require.NoError(t, err) + requireSuiKeyCount(t, app, 1) + set := flag.NewFlagSet("test", 0) + flagSetApplyFromAction(cmd.NewSuiKeysClient(client).DeleteKey, set, "sui") + + 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.NewSuiKeysClient(client).DeleteKey(c) + require.NoError(t, err) + requireSuiKeyCount(t, app, 0) + }) + + t.Run("ImportExportSuiKey", func(tt *testing.T) { + defer cleanup() + defer deleteKeyExportFile(t) + ctx := testutils.Context(t) + client, _ := app.NewShellAndRenderer() + + _, err := app.GetKeyStore().Sui().Create(ctx) + require.NoError(t, err) + + keys := requireSuiKeyCount(t, app, 1) + key := keys[0] + keyName := keyNameForTest(t) + + // Export test invalid id + set := flag.NewFlagSet("test Sui export", 0) + flagSetApplyFromAction(cmd.NewSuiKeysClient(client).ExportKey, set, "sui") + + 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.NewSuiKeysClient(client).ExportKey(c) + require.Error(t, err, "Error exporting") + require.Error(t, utils.JustError(os.Stat(keyName))) + + // Export test + set = flag.NewFlagSet("test Sui export", 0) + flagSetApplyFromAction(cmd.NewSuiKeysClient(client).ExportKey, set, "sui") + + 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.NewSuiKeysClient(client).ExportKey(c)) + require.NoError(t, utils.JustError(os.Stat(keyName))) + + require.NoError(t, utils.JustError(app.GetKeyStore().Sui().Delete(ctx, key.ID()))) + requireSuiKeyCount(t, app, 0) + + set = flag.NewFlagSet("test Sui import", 0) + flagSetApplyFromAction(cmd.NewSuiKeysClient(client).ImportKey, set, "sui") + + 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.NewSuiKeysClient(client).ImportKey(c)) + + requireSuiKeyCount(t, app, 1) + }) +} + +func requireSuiKeyCount(t *testing.T, app chainlink.Application, length int) []suikey.Key { + t.Helper() + keys, err := app.GetKeyStore().Sui().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 e3235dcc28b..095a7476764 100644 --- a/core/config/app_config.go +++ b/core/config/app_config.go @@ -26,6 +26,7 @@ type AppConfig interface { StarkNetEnabled() bool AptosEnabled() bool TronEnabled() bool + SuiEnabled() bool Validate() error ValidateDB() error diff --git a/core/config/env/env.go b/core/config/env/env.go index f97bb30c808..ba03a86ac8b 100644 --- a/core/config/env/env.go +++ b/core/config/env/env.go @@ -27,6 +27,7 @@ var ( MedianPlugin = NewPlugin("median") MercuryPlugin = NewPlugin("mercury") AptosPlugin = NewPlugin("aptos") + SuiPlugin = NewPlugin("sui") CosmosPlugin = NewPlugin("cosmos") SolanaPlugin = NewPlugin("solana") StarknetPlugin = NewPlugin("starknet") diff --git a/core/internal/cltest/cltest.go b/core/internal/cltest/cltest.go index 76497758c26..99cc2852091 100644 --- a/core/internal/cltest/cltest.go +++ b/core/internal/cltest/cltest.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/suikey" "io" "math/big" "math/rand" @@ -133,6 +134,7 @@ var ( DefaultStarkNetKey = starkkey.MustNewInsecure(keystest.NewRandReaderFromSeed(KeyBigIntSeed)) DefaultAptosKey = aptoskey.MustNewInsecure(keystest.NewRandReaderFromSeed(KeyBigIntSeed)) DefaultTronKey = tronkey.MustNewInsecure(keystest.NewRandReaderFromSeed(KeyBigIntSeed)) + DefaultSuiKey = suikey.MustNewInsecure(keystest.NewRandReaderFromSeed(KeyBigIntSeed)) DefaultVRFKey = vrfkey.MustNewV2XXXTestingOnly(big.NewInt(KeyBigIntSeed)) ) diff --git a/core/services/chainlink/application.go b/core/services/chainlink/application.go index d243f231939..213ba85d991 100644 --- a/core/services/chainlink/application.go +++ b/core/services/chainlink/application.go @@ -349,6 +349,9 @@ func NewApplication(ctx context.Context, opts ApplicationOpts) (Application, err if cfg.TronEnabled() { initOps = append(initOps, InitTron(relayerFactory, keyStore.Tron(), cfg.TronConfigs())) } + if cfg.SuiEnabled() { + initOps = append(initOps, InitSui(relayerFactory, keyStore.Sui(), cfg.SuiConfigs())) + } relayChainInterops, err := NewCoreRelayerChainInteroperators(initOps...) if err != nil { diff --git a/core/services/chainlink/config.go b/core/services/chainlink/config.go index 8031e3ca4ff..ad013ccff84 100644 --- a/core/services/chainlink/config.go +++ b/core/services/chainlink/config.go @@ -45,6 +45,8 @@ type Config struct { Aptos RawConfigs `toml:",omitempty"` Tron RawConfigs `toml:",omitempty"` + + Sui RawConfigs `toml:",omitempty"` } // RawConfigs is a list of RawConfig. @@ -341,6 +343,8 @@ func (c *Config) setDefaults() { c.Starknet.SetDefaults() c.Tron.SetDefaults() + + c.Sui.SetDefaults() } func (c *Config) SetFrom(f *Config) (err error) { @@ -370,6 +374,10 @@ func (c *Config) SetFrom(f *Config) (err error) { err = multierr.Append(err, commonconfig.NamedMultiErrorList(err6, "Tron")) } + if err7 := c.Sui.SetFrom(f.Sui); err7 != nil { + err = multierr.Append(err, commonconfig.NamedMultiErrorList(err7, "Sui")) + } + _, err = commonconfig.MultiErrorList(err) return err diff --git a/core/services/chainlink/config_general.go b/core/services/chainlink/config_general.go index 1061028b7f4..69ed78ca6db 100644 --- a/core/services/chainlink/config_general.go +++ b/core/services/chainlink/config_general.go @@ -214,6 +214,10 @@ func (g *generalConfig) AptosConfigs() RawConfigs { return g.c.Aptos } +func (g *generalConfig) SuiConfigs() RawConfigs { + return g.c.Sui +} + func (g *generalConfig) TronConfigs() RawConfigs { return g.c.Tron } @@ -356,6 +360,15 @@ func (g *generalConfig) TronEnabled() bool { return false } +func (g *generalConfig) SuiEnabled() bool { + for _, c := range g.c.Sui { + 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/mocks/general_config.go b/core/services/chainlink/mocks/general_config.go index 6301afeac16..ac2a2625ed0 100644 --- a/core/services/chainlink/mocks/general_config.go +++ b/core/services/chainlink/mocks/general_config.go @@ -1955,6 +1955,98 @@ func (_c *GeneralConfig_StarknetConfigs_Call) RunAndReturn(run func() chainlink. return _c } +// SuiConfigs provides a mock function with no fields +func (_m *GeneralConfig) SuiConfigs() chainlink.RawConfigs { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for SuiConfigs") + } + + 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_SuiConfigs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SuiConfigs' +type GeneralConfig_SuiConfigs_Call struct { + *mock.Call +} + +// SuiConfigs is a helper method to define mock.On call +func (_e *GeneralConfig_Expecter) SuiConfigs() *GeneralConfig_SuiConfigs_Call { + return &GeneralConfig_SuiConfigs_Call{Call: _e.mock.On("SuiConfigs")} +} + +func (_c *GeneralConfig_SuiConfigs_Call) Run(run func()) *GeneralConfig_SuiConfigs_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *GeneralConfig_SuiConfigs_Call) Return(_a0 chainlink.RawConfigs) *GeneralConfig_SuiConfigs_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *GeneralConfig_SuiConfigs_Call) RunAndReturn(run func() chainlink.RawConfigs) *GeneralConfig_SuiConfigs_Call { + _c.Call.Return(run) + return _c +} + +// SuiEnabled provides a mock function with no fields +func (_m *GeneralConfig) SuiEnabled() bool { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for SuiEnabled") + } + + var r0 bool + if rf, ok := ret.Get(0).(func() bool); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} + +// GeneralConfig_SuiEnabled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SuiEnabled' +type GeneralConfig_SuiEnabled_Call struct { + *mock.Call +} + +// SuiEnabled is a helper method to define mock.On call +func (_e *GeneralConfig_Expecter) SuiEnabled() *GeneralConfig_SuiEnabled_Call { + return &GeneralConfig_SuiEnabled_Call{Call: _e.mock.On("SuiEnabled")} +} + +func (_c *GeneralConfig_SuiEnabled_Call) Run(run func()) *GeneralConfig_SuiEnabled_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *GeneralConfig_SuiEnabled_Call) Return(_a0 bool) *GeneralConfig_SuiEnabled_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *GeneralConfig_SuiEnabled_Call) RunAndReturn(run func() bool) *GeneralConfig_SuiEnabled_Call { + _c.Call.Return(run) + return _c +} + // Telemetry provides a mock function with no fields func (_m *GeneralConfig) Telemetry() config.Telemetry { ret := _m.Called() diff --git a/core/services/chainlink/relayer_chain_interoperators.go b/core/services/chainlink/relayer_chain_interoperators.go index 99cfb9566fa..59e2f0dcd03 100644 --- a/core/services/chainlink/relayer_chain_interoperators.go +++ b/core/services/chainlink/relayer_chain_interoperators.go @@ -224,6 +224,24 @@ func InitTron(factory RelayerFactory, ks keystore.Tron, chainCfgs RawConfigs) Co } } +// InitSui is a option for instantiating Sui relayers +func InitSui(factory RelayerFactory, ks keystore.Sui, chainCfgs RawConfigs) CoreRelayerChainInitFunc { + return func(op *CoreRelayerChainInteroperators) (err error) { + loopKs := &keystore.SuiLoopSinger{Sui: ks} + relayers, err := factory.NewSui(loopKs, chainCfgs) + if err != nil { + return fmt.Errorf("failed to setup aptos relayer: %w", err) + } + + for id, relayer := range relayers { + 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_factory.go b/core/services/chainlink/relayer_factory.go index 6f869cd72eb..8fbd49416ba 100644 --- a/core/services/chainlink/relayer_factory.go +++ b/core/services/chainlink/relayer_factory.go @@ -197,6 +197,10 @@ func (r *RelayerFactory) NewAptos(ks coretypes.Keystore, chainCfgs RawConfigs) ( return r.NewLOOPRelayer("Aptos", relay.NetworkAptos, env.AptosPlugin, ks, chainCfgs) } +func (r *RelayerFactory) NewSui(ks coretypes.Keystore, chainCfgs RawConfigs) (map[types.RelayID]loop.Relayer, error) { + return r.NewLOOPRelayer("Sui", relay.NetworkSui, env.SuiPlugin, ks, chainCfgs) +} + func (r *RelayerFactory) NewLOOPRelayer(name string, network string, plugin env.Plugin, ks coretypes.Keystore, chainCfgs RawConfigs) (map[types.RelayID]loop.Relayer, error) { relayers := make(map[types.RelayID]loop.Relayer) lggr := r.Logger.Named(name) diff --git a/core/services/chainlink/types.go b/core/services/chainlink/types.go index e7ed45e0287..2ff873e58cf 100644 --- a/core/services/chainlink/types.go +++ b/core/services/chainlink/types.go @@ -16,6 +16,7 @@ type GeneralConfig interface { StarknetConfigs() RawConfigs AptosConfigs() RawConfigs TronConfigs() RawConfigs + SuiConfigs() RawConfigs // ConfigTOML returns both the user provided and effective configuration as TOML. ConfigTOML() (user, effective string) ImportedSecretConfig diff --git a/core/services/feeds/models.go b/core/services/feeds/models.go index a6cf103b4e9..46f056740ff 100644 --- a/core/services/feeds/models.go +++ b/core/services/feeds/models.go @@ -84,6 +84,7 @@ const ( ChainTypeSolana ChainType = "SOLANA" ChainTypeStarknet ChainType = "STARKNET" ChainTypeTron ChainType = "TRON" + ChainTypeSui ChainType = "SUI" ) func NewChainType(s string) (ChainType, error) { @@ -98,6 +99,8 @@ func NewChainType(s string) (ChainType, error) { return ChainTypeAptos, nil case "TRON": return ChainTypeTron, nil + case "SUI": + return ChainTypeSui, nil default: return ChainTypeUnknown, errors.New("invalid chain type") } diff --git a/core/services/feeds/service_test.go b/core/services/feeds/service_test.go index 5a354c38ffb..fbd4cd564f8 100644 --- a/core/services/feeds/service_test.go +++ b/core/services/feeds/service_test.go @@ -588,6 +588,13 @@ func Test_Service_CreateChainConfig(t *testing.T) { expectedID: int64(1), expectedChainType: proto.ChainType_CHAIN_TYPE_TRON, }, + // + //{ + // name: "Sui Chain Type", + // chainType: feeds.ChainTypeSui, + // expectedID: int64(1), + // expectedChainType: proto.ChainType_CHAIN_TYPE_SUI, + //}, } for _, tt := range tests { diff --git a/core/services/keystore/chaintype/chaintype.go b/core/services/keystore/chaintype/chaintype.go index 8aca72d4f83..161c33f48f9 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" + // Sui for the Sui chain + Sui ChainType = "sui" // Tron for the Tron chain Tron ChainType = "tron" ) diff --git a/core/services/keystore/keys/ocr2key/aptos_keyring.go b/core/services/keystore/keys/ocr2key/ed25519_keyring.go similarity index 66% rename from core/services/keystore/keys/ocr2key/aptos_keyring.go rename to core/services/keystore/keys/ocr2key/ed25519_keyring.go index 393ddd4eb5a..7b7f42df0c4 100644 --- a/core/services/keystore/keys/ocr2key/aptos_keyring.go +++ b/core/services/keystore/keys/ocr2key/ed25519_keyring.go @@ -15,26 +15,26 @@ import ( ocrtypes "github.com/smartcontractkit/libocr/offchainreporting2plus/types" ) -var _ ocrtypes.OnchainKeyring = &aptosKeyring{} +var _ ocrtypes.OnchainKeyring = &ed25519Keyring{} -type aptosKeyring struct { +type ed25519Keyring struct { privKey func() ed25519.PrivateKey pubKey ed25519.PublicKey } -func newAptosKeyring(material io.Reader) (*aptosKeyring, error) { +func newEd25519Keyring(material io.Reader) (*ed25519Keyring, error) { pubKey, privKey, err := ed25519.GenerateKey(material) if err != nil { return nil, err } - return &aptosKeyring{pubKey: pubKey, privKey: func() ed25519.PrivateKey { return privKey }}, nil + return &ed25519Keyring{pubKey: pubKey, privKey: func() ed25519.PrivateKey { return privKey }}, nil } -func (akr *aptosKeyring) PublicKey() ocrtypes.OnchainPublicKey { +func (akr *ed25519Keyring) PublicKey() ocrtypes.OnchainPublicKey { return []byte(akr.pubKey) } -func (akr *aptosKeyring) reportToSigData(reportCtx ocrtypes.ReportContext, report ocrtypes.Report) ([]byte, error) { +func (akr *ed25519Keyring) reportToSigData(reportCtx ocrtypes.ReportContext, report ocrtypes.Report) ([]byte, error) { rawReportContext := evmutil.RawReportContext(reportCtx) h, err := blake2b.New256(nil) if err != nil { @@ -48,7 +48,7 @@ func (akr *aptosKeyring) reportToSigData(reportCtx ocrtypes.ReportContext, repor return h.Sum(nil), nil } -func (akr *aptosKeyring) Sign(reportCtx ocrtypes.ReportContext, report ocrtypes.Report) ([]byte, error) { +func (akr *ed25519Keyring) Sign(reportCtx ocrtypes.ReportContext, report ocrtypes.Report) ([]byte, error) { sigData, err := akr.reportToSigData(reportCtx, report) if err != nil { return nil, err @@ -56,7 +56,7 @@ func (akr *aptosKeyring) Sign(reportCtx ocrtypes.ReportContext, report ocrtypes. return akr.SignBlob(sigData) } -func (ekr *aptosKeyring) reportToSigData3(digest types.ConfigDigest, seqNr uint64, r ocrtypes.Report) ([]byte, error) { +func (ekr *ed25519Keyring) reportToSigData3(digest types.ConfigDigest, seqNr uint64, r ocrtypes.Report) ([]byte, error) { rawReportContext := RawReportContext3(digest, seqNr) h, err := blake2b.New256(nil) if err != nil { @@ -68,7 +68,7 @@ func (ekr *aptosKeyring) reportToSigData3(digest types.ConfigDigest, seqNr uint6 return h.Sum(nil), nil } -func (akr *aptosKeyring) Sign3(digest types.ConfigDigest, seqNr uint64, r ocrtypes.Report) (signature []byte, err error) { +func (akr *ed25519Keyring) Sign3(digest types.ConfigDigest, seqNr uint64, r ocrtypes.Report) (signature []byte, err error) { sigData, err := akr.reportToSigData3(digest, seqNr, r) if err != nil { return nil, err @@ -76,13 +76,13 @@ func (akr *aptosKeyring) Sign3(digest types.ConfigDigest, seqNr uint64, r ocrtyp return akr.SignBlob(sigData) } -func (akr *aptosKeyring) SignBlob(b []byte) ([]byte, error) { +func (akr *ed25519Keyring) SignBlob(b []byte) ([]byte, error) { signedMsg := ed25519.Sign(akr.privKey(), b) // match on-chain parsing (first 32 bytes are for pubkey, remaining are for signature) return utils.ConcatBytes(akr.PublicKey(), signedMsg), nil } -func (akr *aptosKeyring) Verify(publicKey ocrtypes.OnchainPublicKey, reportCtx ocrtypes.ReportContext, report ocrtypes.Report, signature []byte) bool { +func (akr *ed25519Keyring) Verify(publicKey ocrtypes.OnchainPublicKey, reportCtx ocrtypes.ReportContext, report ocrtypes.Report, signature []byte) bool { hash, err := akr.reportToSigData(reportCtx, report) if err != nil { return false @@ -90,7 +90,7 @@ func (akr *aptosKeyring) Verify(publicKey ocrtypes.OnchainPublicKey, reportCtx o return akr.VerifyBlob(publicKey, hash, signature) } -func (akr *aptosKeyring) Verify3(publicKey ocrtypes.OnchainPublicKey, digest ocrtypes.ConfigDigest, seqNr uint64, r ocrtypes.Report, signature []byte) bool { +func (akr *ed25519Keyring) Verify3(publicKey ocrtypes.OnchainPublicKey, digest ocrtypes.ConfigDigest, seqNr uint64, r ocrtypes.Report, signature []byte) bool { sigData, err := akr.reportToSigData3(digest, seqNr, r) if err != nil { return false @@ -98,7 +98,7 @@ func (akr *aptosKeyring) Verify3(publicKey ocrtypes.OnchainPublicKey, digest ocr return akr.VerifyBlob(publicKey, sigData, signature) } -func (akr *aptosKeyring) VerifyBlob(pubkey ocrtypes.OnchainPublicKey, b, sig []byte) bool { +func (akr *ed25519Keyring) VerifyBlob(pubkey ocrtypes.OnchainPublicKey, b, sig []byte) bool { // Ed25519 signatures are always 64 bytes and the // public key (always prefixed, see Sign above) is always, // 32 bytes, so we always require the max signature length. @@ -111,16 +111,16 @@ func (akr *aptosKeyring) VerifyBlob(pubkey ocrtypes.OnchainPublicKey, b, sig []b return ed25519consensus.Verify(ed25519.PublicKey(pubkey), b, sig[32:]) } -func (akr *aptosKeyring) MaxSignatureLength() int { +func (akr *ed25519Keyring) MaxSignatureLength() int { // Reference: https://pkg.go.dev/crypto/ed25519 return ed25519.PublicKeySize + ed25519.SignatureSize // 32 + 64 } -func (akr *aptosKeyring) Marshal() ([]byte, error) { +func (akr *ed25519Keyring) Marshal() ([]byte, error) { return akr.privKey().Seed(), nil } -func (akr *aptosKeyring) Unmarshal(in []byte) error { +func (akr *ed25519Keyring) Unmarshal(in []byte) error { if len(in) != ed25519.SeedSize { return errors.Errorf("unexpected seed size, got %d want %d", len(in), ed25519.SeedSize) } diff --git a/core/services/keystore/keys/ocr2key/aptos_keyring_test.go b/core/services/keystore/keys/ocr2key/ed25519_keyring_test.go similarity index 82% rename from core/services/keystore/keys/ocr2key/aptos_keyring_test.go rename to core/services/keystore/keys/ocr2key/ed25519_keyring_test.go index 81599780b03..03b4600019c 100644 --- a/core/services/keystore/keys/ocr2key/aptos_keyring_test.go +++ b/core/services/keystore/keys/ocr2key/ed25519_keyring_test.go @@ -12,10 +12,10 @@ import ( ocrtypes "github.com/smartcontractkit/libocr/offchainreporting2plus/types" ) -func TestAptosKeyRing_Sign_Verify(t *testing.T) { - kr1, err := newAptosKeyring(cryptorand.Reader) +func TestEd25519KeyRing_Sign_Verify(t *testing.T) { + kr1, err := newEd25519Keyring(cryptorand.Reader) require.NoError(t, err) - kr2, err := newAptosKeyring(cryptorand.Reader) + kr2, err := newEd25519Keyring(cryptorand.Reader) require.NoError(t, err) ctx := ocrtypes.ReportContext{} @@ -43,12 +43,12 @@ func TestAptosKeyRing_Sign_Verify(t *testing.T) { }) } -func TestAptosKeyRing_Marshalling(t *testing.T) { - kr1, err := newAptosKeyring(cryptorand.Reader) +func TestEd25519KeyRing_Marshalling(t *testing.T) { + kr1, err := newEd25519Keyring(cryptorand.Reader) require.NoError(t, err) m, err := kr1.Marshal() require.NoError(t, err) - kr2 := aptosKeyring{} + kr2 := ed25519Keyring{} err = kr2.Unmarshal(m) require.NoError(t, err) assert.True(t, bytes.Equal(kr1.pubKey, kr2.pubKey)) diff --git a/core/services/keystore/keys/ocr2key/export.go b/core/services/keystore/keys/ocr2key/export.go index 80016c82e4b..540b542e185 100644 --- a/core/services/keystore/keys/ocr2key/export.go +++ b/core/services/keystore/keys/ocr2key/export.go @@ -47,7 +47,9 @@ func FromEncryptedJSON(keyJSON []byte, password string) (KeyBundle, error) { case chaintype.StarkNet: kb = newKeyBundle(new(starkkey.OCR2Key)) case chaintype.Aptos: - kb = newKeyBundle(new(aptosKeyring)) + kb = newKeyBundle(new(ed25519Keyring)) + case chaintype.Sui: + kb = newKeyBundle(new(ed25519Keyring)) case chaintype.Tron: kb = newKeyBundle(new(evmKeyring)) default: diff --git a/core/services/keystore/keys/ocr2key/key_bundle.go b/core/services/keystore/keys/ocr2key/key_bundle.go index df75e62e958..1b152117066 100644 --- a/core/services/keystore/keys/ocr2key/key_bundle.go +++ b/core/services/keystore/keys/ocr2key/key_bundle.go @@ -45,7 +45,7 @@ var _ KeyBundle = &keyBundle[*evmKeyring]{} var _ KeyBundle = &keyBundle[*cosmosKeyring]{} var _ KeyBundle = &keyBundle[*solanaKeyring]{} var _ KeyBundle = &keyBundle[*starkkey.OCR2Key]{} -var _ KeyBundle = &keyBundle[*aptosKeyring]{} +var _ KeyBundle = &keyBundle[*ed25519Keyring]{} var curve = secp256k1.S256() @@ -61,7 +61,9 @@ func New(chainType chaintype.ChainType) (KeyBundle, error) { case chaintype.StarkNet: return newKeyBundleRand(chaintype.StarkNet, starkkey.NewOCR2Key) case chaintype.Aptos: - return newKeyBundleRand(chaintype.Aptos, newAptosKeyring) + return newKeyBundleRand(chaintype.Aptos, newEd25519Keyring) + case chaintype.Sui: + return newKeyBundleRand(chaintype.Sui, newEd25519Keyring) case chaintype.Tron: return newKeyBundleRand(chaintype.Tron, newEVMKeyring) } @@ -80,7 +82,9 @@ func MustNewInsecure(reader io.Reader, chainType chaintype.ChainType) KeyBundle case chaintype.StarkNet: return mustNewKeyBundleInsecure(chaintype.StarkNet, starkkey.NewOCR2Key, reader) case chaintype.Aptos: - return mustNewKeyBundleInsecure(chaintype.Aptos, newAptosKeyring, reader) + return mustNewKeyBundleInsecure(chaintype.Aptos, newEd25519Keyring, reader) + case chaintype.Sui: + return mustNewKeyBundleInsecure(chaintype.Sui, newEd25519Keyring, reader) case chaintype.Tron: return mustNewKeyBundleInsecure(chaintype.Tron, newEVMKeyring, reader) } @@ -118,7 +122,9 @@ func KeyFor(raw internal.Raw) (kb KeyBundle) { case chaintype.StarkNet: kb = newKeyBundle(new(starkkey.OCR2Key)) case chaintype.Aptos: - kb = newKeyBundle(new(aptosKeyring)) + kb = newKeyBundle(new(ed25519Keyring)) + case chaintype.Sui: + kb = newKeyBundle(new(ed25519Keyring)) case chaintype.Tron: kb = newKeyBundle(new(evmKeyring)) default: diff --git a/core/services/keystore/keys/suikey/export.go b/core/services/keystore/keys/suikey/export.go new file mode 100644 index 00000000000..1ec82ea1545 --- /dev/null +++ b/core/services/keystore/keys/suikey/export.go @@ -0,0 +1,47 @@ +package suikey + +import ( + "encoding/hex" + + "github.com/ethereum/go-ethereum/accounts/keystore" + + "github.com/smartcontractkit/chainlink/v2/core/services/keystore/internal" + "github.com/smartcontractkit/chainlink/v2/core/utils" +) + +const keyTypeIdentifier = "Sui" + +// FromEncryptedJSON gets key from json and password +func FromEncryptedJSON(keyJSON []byte, password string) (Key, error) { + return internal.FromEncryptedJSON( + keyTypeIdentifier, + keyJSON, + password, + adulteratedPassword, + func(_ internal.EncryptedKeyExport, rawPrivKey internal.Raw) (Key, error) { + return KeyFor(rawPrivKey), nil + }, + ) +} + +// ToEncryptedJSON returns encrypted JSON representing key +func (s Key) ToEncryptedJSON(password string, scryptParams utils.ScryptParams) (export []byte, err error) { + return internal.ToEncryptedJSON( + keyTypeIdentifier, + s, + password, + scryptParams, + adulteratedPassword, + func(id string, key Key, cryptoJSON keystore.CryptoJSON) internal.EncryptedKeyExport { + return internal.EncryptedKeyExport{ + KeyType: id, + PublicKey: hex.EncodeToString(key.pubKey), + Crypto: cryptoJSON, + } + }, + ) +} + +func adulteratedPassword(password string) string { + return "suikey" + password +} diff --git a/core/services/keystore/keys/suikey/export_test.go b/core/services/keystore/keys/suikey/export_test.go new file mode 100644 index 00000000000..993629d0e57 --- /dev/null +++ b/core/services/keystore/keys/suikey/export_test.go @@ -0,0 +1,19 @@ +package suikey + +import ( + "testing" + + "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys" +) + +func TestSuiKeys_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/suikey/key.go b/core/services/keystore/keys/suikey/key.go new file mode 100644 index 00000000000..c697d3b719e --- /dev/null +++ b/core/services/keystore/keys/suikey/key.go @@ -0,0 +1,105 @@ +package suikey + +import ( + "crypto" + "crypto/ed25519" + cryptorand "crypto/rand" + "encoding/hex" + "fmt" + "golang.org/x/crypto/blake2b" + "io" + + "github.com/smartcontractkit/chainlink/v2/core/services/keystore/internal" +) + +// Ed25519Scheme Ed25519 signature scheme flag +// https://docs.sui.io/concepts/cryptography/transaction-auth/keys-addresses#address-format +const Ed25519Scheme byte = 0x00 + +// Key represents a Sui account +type Key struct { + raw internal.Raw + signFn func(io.Reader, []byte, crypto.SignerOpts) ([]byte, error) + pubKey ed25519.PublicKey +} + +// KeyFor creates an Account from a raw key +func KeyFor(raw internal.Raw) Key { + privKey := ed25519.NewKeyFromSeed(internal.Bytes(raw)) + pubKey := privKey.Public().(ed25519.PublicKey) + return Key{ + raw: raw, + signFn: privKey.Sign, + pubKey: pubKey, + } +} + +// New creates new Key +func New() (Key, error) { + return newFrom(cryptorand.Reader) +} + +// MustNewInsecure returns an Account if no error +func MustNewInsecure(reader io.Reader) Key { + key, err := newFrom(reader) + if err != nil { + panic(err) + } + return key +} + +// newFrom creates a new Account from a provided random reader +func newFrom(reader io.Reader) (Key, error) { + pub, priv, err := ed25519.GenerateKey(reader) + if err != nil { + return Key{}, err + } + return Key{ + raw: internal.NewRaw(priv.Seed()), + signFn: priv.Sign, + pubKey: pub, + }, nil +} + +// ID gets Account ID +func (s Key) ID() string { + return s.PublicKeyStr() +} + +// Address returns the Sui address +// https://docs.sui.io/concepts/cryptography/transaction-auth/keys-addresses#address-format +func (s Key) Address() string { + // Prepend the Ed25519 scheme flag to the public key + flaggedPubKey := make([]byte, 1+len(s.pubKey)) + flaggedPubKey[0] = Ed25519Scheme + copy(flaggedPubKey[1:], s.pubKey) + + // Hash the flagged public key with Blake2b-256 + addressBytes := blake2b.Sum256(flaggedPubKey) + + // Return the full 32-byte address with 0x prefix + return hex.EncodeToString(addressBytes[:]) +} + +// Account is an alias for Address +func (s Key) Account() string { + return s.Address() +} + +// GetPublic gets Account's public key +func (s Key) GetPublic() ed25519.PublicKey { + return s.pubKey +} + +// PublicKeyStr returns hex encoded public key +func (s Key) PublicKeyStr() string { + return fmt.Sprintf("%064x", s.pubKey) +} + +// Raw returns the seed from private key +func (s Key) Raw() internal.Raw { return s.raw } + +// Sign is used to sign a message +func (s Key) Sign(msg []byte) ([]byte, error) { + return s.signFn(cryptorand.Reader, msg, crypto.Hash(0)) // no specific hash function used +} diff --git a/core/services/keystore/keys/suikey/key_test.go b/core/services/keystore/keys/suikey/key_test.go new file mode 100644 index 00000000000..4f43b0563be --- /dev/null +++ b/core/services/keystore/keys/suikey/key_test.go @@ -0,0 +1,19 @@ +package suikey + +import ( + "encoding/hex" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink/v2/core/services/keystore/internal" +) + +func TestSuiKey(t *testing.T) { + bytes, err := hex.DecodeString("f0d07ab448018b2754475f9a3b580218b0675a1456aad96ad607c7bbd7d9237b") + require.NoError(t, err) + k := KeyFor(internal.NewRaw(bytes)) + assert.Equal(t, "2acd605efc181e2af8a0b8c0686a5e12578efa1253d15a235fa5e5ad970c4b29", k.PublicKeyStr()) + assert.Equal(t, "28ebc047741ac19f2ff459d4ddb8f0c688415650edb103a22e4c66350dbcaff9", k.Account()) +} diff --git a/core/services/keystore/master.go b/core/services/keystore/master.go index 4275c1bc861..f526935a785 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/suikey" "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" @@ -47,6 +48,7 @@ type Master interface { StarkNet() StarkNet Aptos() Aptos Tron() Tron + Sui() Sui VRF() VRF Workflow() Workflow Unlock(ctx context.Context, password string) error @@ -64,12 +66,14 @@ type master struct { starknet *starknet aptos *aptos tron *tron + sui *sui vrf *vrf workflow *workflow } func New(ds sqlutil.DataSource, scryptParams utils.ScryptParams, lggr logger.Logger) Master { - return newMaster(ds, scryptParams, lggr) + master := newMaster(ds, scryptParams, lggr) + return master } func newMaster(ds sqlutil.DataSource, scryptParams utils.ScryptParams, lggr logger.Logger) *master { @@ -94,6 +98,7 @@ func newMaster(ds sqlutil.DataSource, scryptParams utils.ScryptParams, lggr logg starknet: newStarkNetKeyStore(km), aptos: newAptosKeyStore(km), tron: newTronKeyStore(km), + sui: newSuiKeyStore(km), vrf: newVRFKeyStore(km), workflow: newWorkflowKeyStore(km), } @@ -139,6 +144,10 @@ func (ks *master) Tron() Tron { return ks.tron } +func (ks *master) Sui() Sui { + return ks.sui +} + func (ks *master) VRF() VRF { return ks.vrf } @@ -282,6 +291,8 @@ func GetFieldNameForKey(unknownKey Key) (string, error) { return "Aptos", nil case tronkey.Key: return "Tron", nil + case suikey.Key: + return "Sui", 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 a1d43972556..5047c81d6d2 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 } +// Sui provides a mock function with no fields +func (_m *Master) Sui() keystore.Sui { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Sui") + } + + var r0 keystore.Sui + if rf, ok := ret.Get(0).(func() keystore.Sui); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(keystore.Sui) + } + } + + return r0 +} + +// Master_Sui_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Sui' +type Master_Sui_Call struct { + *mock.Call +} + +// Sui is a helper method to define mock.On call +func (_e *Master_Expecter) Sui() *Master_Sui_Call { + return &Master_Sui_Call{Call: _e.mock.On("Sui")} +} + +func (_c *Master_Sui_Call) Run(run func()) *Master_Sui_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *Master_Sui_Call) Return(_a0 keystore.Sui) *Master_Sui_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Master_Sui_Call) RunAndReturn(run func() keystore.Sui) *Master_Sui_Call { + _c.Call.Return(run) + return _c +} + // Tron provides a mock function with no fields func (_m *Master) Tron() keystore.Tron { ret := _m.Called() diff --git a/core/services/keystore/mocks/sui.go b/core/services/keystore/mocks/sui.go new file mode 100644 index 00000000000..0317f698fd0 --- /dev/null +++ b/core/services/keystore/mocks/sui.go @@ -0,0 +1,534 @@ +// Code generated by mockery v2.53.0. DO NOT EDIT. + +package mocks + +import ( + context "context" + + suikey "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/suikey" + + mock "github.com/stretchr/testify/mock" +) + +// Sui is an autogenerated mock type for the Sui type +type Sui struct { + mock.Mock +} + +type Sui_Expecter struct { + mock *mock.Mock +} + +func (_m *Sui) EXPECT() *Sui_Expecter { + return &Sui_Expecter{mock: &_m.Mock} +} + +// Add provides a mock function with given fields: ctx, key +func (_m *Sui) Add(ctx context.Context, key suikey.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, suikey.Key) error); ok { + r0 = rf(ctx, key) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Sui_Add_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Add' +type Sui_Add_Call struct { + *mock.Call +} + +// Add is a helper method to define mock.On call +// - ctx context.Context +// - key aptoskey.Key +func (_e *Sui_Expecter) Add(ctx interface{}, key interface{}) *Sui_Add_Call { + return &Sui_Add_Call{Call: _e.mock.On("Add", ctx, key)} +} + +func (_c *Sui_Add_Call) Run(run func(ctx context.Context, key suikey.Key)) *Sui_Add_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(suikey.Key)) + }) + return _c +} + +func (_c *Sui_Add_Call) Return(_a0 error) *Sui_Add_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Sui_Add_Call) RunAndReturn(run func(context.Context, suikey.Key) error) *Sui_Add_Call { + _c.Call.Return(run) + return _c +} + +// Create provides a mock function with given fields: ctx +func (_m *Sui) Create(ctx context.Context) (suikey.Key, error) { + ret := _m.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for Create") + } + + var r0 suikey.Key + var r1 error + if rf, ok := ret.Get(0).(func(context.Context) (suikey.Key, error)); ok { + return rf(ctx) + } + if rf, ok := ret.Get(0).(func(context.Context) suikey.Key); ok { + r0 = rf(ctx) + } else { + r0 = ret.Get(0).(suikey.Key) + } + + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Sui_Create_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Create' +type Sui_Create_Call struct { + *mock.Call +} + +// Create is a helper method to define mock.On call +// - ctx context.Context +func (_e *Sui_Expecter) Create(ctx interface{}) *Sui_Create_Call { + return &Sui_Create_Call{Call: _e.mock.On("Create", ctx)} +} + +func (_c *Sui_Create_Call) Run(run func(ctx context.Context)) *Sui_Create_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context)) + }) + return _c +} + +func (_c *Sui_Create_Call) Return(_a0 suikey.Key, _a1 error) *Sui_Create_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Sui_Create_Call) RunAndReturn(run func(context.Context) (suikey.Key, error)) *Sui_Create_Call { + _c.Call.Return(run) + return _c +} + +// Delete provides a mock function with given fields: ctx, id +func (_m *Sui) Delete(ctx context.Context, id string) (suikey.Key, error) { + ret := _m.Called(ctx, id) + + if len(ret) == 0 { + panic("no return value specified for Delete") + } + + var r0 suikey.Key + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) (suikey.Key, error)); ok { + return rf(ctx, id) + } + if rf, ok := ret.Get(0).(func(context.Context, string) suikey.Key); ok { + r0 = rf(ctx, id) + } else { + r0 = ret.Get(0).(suikey.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 +} + +// Sui_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete' +type Sui_Delete_Call struct { + *mock.Call +} + +// Delete is a helper method to define mock.On call +// - ctx context.Context +// - id string +func (_e *Sui_Expecter) Delete(ctx interface{}, id interface{}) *Sui_Delete_Call { + return &Sui_Delete_Call{Call: _e.mock.On("Delete", ctx, id)} +} + +func (_c *Sui_Delete_Call) Run(run func(ctx context.Context, id string)) *Sui_Delete_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *Sui_Delete_Call) Return(_a0 suikey.Key, _a1 error) *Sui_Delete_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Sui_Delete_Call) RunAndReturn(run func(context.Context, string) (suikey.Key, error)) *Sui_Delete_Call { + _c.Call.Return(run) + return _c +} + +// EnsureKey provides a mock function with given fields: ctx +func (_m *Sui) 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 +} + +// Aptos_EnsureKey_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnsureKey' +type Sui_EnsureKey_Call struct { + *mock.Call +} + +// EnsureKey is a helper method to define mock.On call +// - ctx context.Context +func (_e *Sui_Expecter) EnsureKey(ctx interface{}) *Sui_EnsureKey_Call { + return &Sui_EnsureKey_Call{Call: _e.mock.On("EnsureKey", ctx)} +} + +func (_c *Sui_EnsureKey_Call) Run(run func(ctx context.Context)) *Sui_EnsureKey_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context)) + }) + return _c +} + +func (_c *Sui_EnsureKey_Call) Return(_a0 error) *Sui_EnsureKey_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Sui_EnsureKey_Call) RunAndReturn(run func(context.Context) error) *Sui_EnsureKey_Call { + _c.Call.Return(run) + return _c +} + +// Export provides a mock function with given fields: id, password +func (_m *Sui) 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 +} + +// Sui_Export_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Export' +type Sui_Export_Call struct { + *mock.Call +} + +// Export is a helper method to define mock.On call +// - id string +// - password string +func (_e *Sui_Expecter) Export(id interface{}, password interface{}) *Sui_Export_Call { + return &Sui_Export_Call{Call: _e.mock.On("Export", id, password)} +} + +func (_c *Sui_Export_Call) Run(run func(id string, password string)) *Sui_Export_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(string)) + }) + return _c +} + +func (_c *Sui_Export_Call) Return(_a0 []byte, _a1 error) *Sui_Export_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Sui_Export_Call) RunAndReturn(run func(string, string) ([]byte, error)) *Sui_Export_Call { + _c.Call.Return(run) + return _c +} + +// Get provides a mock function with given fields: id +func (_m *Sui) Get(id string) (suikey.Key, error) { + ret := _m.Called(id) + + if len(ret) == 0 { + panic("no return value specified for Get") + } + + var r0 suikey.Key + var r1 error + if rf, ok := ret.Get(0).(func(string) (suikey.Key, error)); ok { + return rf(id) + } + if rf, ok := ret.Get(0).(func(string) suikey.Key); ok { + r0 = rf(id) + } else { + r0 = ret.Get(0).(suikey.Key) + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Sui_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get' +type Sui_Get_Call struct { + *mock.Call +} + +// Get is a helper method to define mock.On call +// - id string +func (_e *Sui_Expecter) Get(id interface{}) *Sui_Get_Call { + return &Sui_Get_Call{Call: _e.mock.On("Get", id)} +} + +func (_c *Sui_Get_Call) Run(run func(id string)) *Sui_Get_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *Sui_Get_Call) Return(_a0 suikey.Key, _a1 error) *Sui_Get_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Sui_Get_Call) RunAndReturn(run func(string) (suikey.Key, error)) *Sui_Get_Call { + _c.Call.Return(run) + return _c +} + +// GetAll provides a mock function with no fields +func (_m *Sui) GetAll() ([]suikey.Key, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetAll") + } + + var r0 []suikey.Key + var r1 error + if rf, ok := ret.Get(0).(func() ([]suikey.Key, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() []suikey.Key); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]suikey.Key) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Sui_GetAll_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAll' +type Sui_GetAll_Call struct { + *mock.Call +} + +// GetAll is a helper method to define mock.On call +func (_e *Sui_Expecter) GetAll() *Sui_GetAll_Call { + return &Sui_GetAll_Call{Call: _e.mock.On("GetAll")} +} + +func (_c *Sui_GetAll_Call) Run(run func()) *Sui_GetAll_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *Sui_GetAll_Call) Return(_a0 []suikey.Key, _a1 error) *Sui_GetAll_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Sui_GetAll_Call) RunAndReturn(run func() ([]suikey.Key, error)) *Sui_GetAll_Call { + _c.Call.Return(run) + return _c +} + +// Import provides a mock function with given fields: ctx, keyJSON, password +func (_m *Sui) Import(ctx context.Context, keyJSON []byte, password string) (suikey.Key, error) { + ret := _m.Called(ctx, keyJSON, password) + + if len(ret) == 0 { + panic("no return value specified for Import") + } + + var r0 suikey.Key + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, []byte, string) (suikey.Key, error)); ok { + return rf(ctx, keyJSON, password) + } + if rf, ok := ret.Get(0).(func(context.Context, []byte, string) suikey.Key); ok { + r0 = rf(ctx, keyJSON, password) + } else { + r0 = ret.Get(0).(suikey.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 +} + +// Sui_Import_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Import' +type Sui_Import_Call struct { + *mock.Call +} + +// Import is a helper method to define mock.On call +// - ctx context.Context +// - keyJSON []byte +// - password string +func (_e *Sui_Expecter) Import(ctx interface{}, keyJSON interface{}, password interface{}) *Sui_Import_Call { + return &Sui_Import_Call{Call: _e.mock.On("Import", ctx, keyJSON, password)} +} + +func (_c *Sui_Import_Call) Run(run func(ctx context.Context, keyJSON []byte, password string)) *Sui_Import_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].([]byte), args[2].(string)) + }) + return _c +} + +func (_c *Sui_Import_Call) Return(_a0 suikey.Key, _a1 error) *Sui_Import_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Sui_Import_Call) RunAndReturn(run func(context.Context, []byte, string) (suikey.Key, error)) *Sui_Import_Call { + _c.Call.Return(run) + return _c +} + +// Sign provides a mock function with given fields: ctx, id, msg +func (_m *Sui) 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 +} + +// Sui_Sign_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Sign' +type Sui_Sign_Call struct { + *mock.Call +} + +// Sign is a helper method to define mock.On call +// - ctx context.Context +// - id string +// - msg []byte +func (_e *Sui_Expecter) Sign(ctx interface{}, id interface{}, msg interface{}) *Sui_Sign_Call { + return &Sui_Sign_Call{Call: _e.mock.On("Sign", ctx, id, msg)} +} + +func (_c *Sui_Sign_Call) Run(run func(ctx context.Context, id string, msg []byte)) *Sui_Sign_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].([]byte)) + }) + return _c +} + +func (_c *Sui_Sign_Call) Return(signature []byte, err error) *Sui_Sign_Call { + _c.Call.Return(signature, err) + return _c +} + +func (_c *Sui_Sign_Call) RunAndReturn(run func(context.Context, string, []byte) ([]byte, error)) *Sui_Sign_Call { + _c.Call.Return(run) + return _c +} + +// NewSui creates a new instance of Sui. 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 NewSui(t interface { + mock.TestingT + Cleanup(func()) +}) *Sui { + mock := &Sui{} + 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 9bf2f9252d4..ac8049f3309 100644 --- a/core/services/keystore/models.go +++ b/core/services/keystore/models.go @@ -21,6 +21,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/suikey" "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" @@ -161,6 +162,7 @@ type keyRing struct { StarkNet map[string]starkkey.Key Aptos map[string]aptoskey.Key Tron map[string]tronkey.Key + Sui map[string]suikey.Key VRF map[string]vrfkey.KeyV2 Workflow map[string]workflowkey.Key LegacyKeys LegacyKeyStorage @@ -178,6 +180,7 @@ func newKeyRing() *keyRing { StarkNet: make(map[string]starkkey.Key), Aptos: make(map[string]aptoskey.Key), Tron: make(map[string]tronkey.Key), + Sui: make(map[string]suikey.Key), VRF: make(map[string]vrfkey.KeyV2), Workflow: make(map[string]workflowkey.Key), } @@ -243,6 +246,9 @@ func (kr *keyRing) raw() (rawKeys rawKeyRing) { for _, tronkey := range kr.Tron { rawKeys.Tron = append(rawKeys.Tron, internal.RawBytes(tronkey)) } + for _, suikey := range kr.Sui { + rawKeys.Sui = append(rawKeys.Sui, internal.RawBytes(suikey)) + } for _, vrfKey := range kr.VRF { rawKeys.VRF = append(rawKeys.VRF, internal.RawBytes(vrfKey)) } @@ -294,6 +300,10 @@ func (kr *keyRing) logPubKeys(lggr logger.Logger) { for _, tronKey := range kr.Tron { tronIDs = append(tronIDs, tronKey.ID()) } + var suiIDs []string + for _, suiKey := range kr.Sui { + suiIDs = append(suiIDs, suiKey.ID()) + } var vrfIDs []string for _, VRFKey := range kr.VRF { vrfIDs = append(vrfIDs, VRFKey.ID()) @@ -334,6 +344,9 @@ func (kr *keyRing) logPubKeys(lggr logger.Logger) { if len(tronIDs) > 0 { lggr.Infow(fmt.Sprintf("Unlocked %d Tron keys", len(tronIDs)), "keys", tronIDs) } + if len(suiIDs) > 0 { + lggr.Infow(fmt.Sprintf("Unlocked %d Sui keys", len(suiIDs)), "keys", suiIDs) + } if len(vrfIDs) > 0 { lggr.Infow(fmt.Sprintf("Unlocked %d VRF keys", len(vrfIDs)), "keys", vrfIDs) } @@ -359,6 +372,7 @@ type rawKeyRing struct { StarkNet [][]byte Aptos [][]byte Tron [][]byte + Sui [][]byte VRF [][]byte Workflow [][]byte LegacyKeys LegacyKeyStorage `json:"-"` @@ -407,6 +421,10 @@ func (rawKeys rawKeyRing) keys() (*keyRing, error) { tronKey := tronkey.KeyFor(internal.NewRaw(rawTronKey)) keyRing.Tron[tronKey.ID()] = tronKey } + for _, rawSuiKey := range rawKeys.Sui { + suiKey := suikey.KeyFor(internal.NewRaw(rawSuiKey)) + keyRing.Sui[suiKey.ID()] = suiKey + } for _, rawVRFKey := range rawKeys.VRF { vrfKey := vrfkey.KeyFor(internal.NewRaw(rawVRFKey)) keyRing.VRF[vrfKey.ID()] = vrfKey diff --git a/core/services/keystore/sui.go b/core/services/keystore/sui.go new file mode 100644 index 00000000000..6ad24e7611d --- /dev/null +++ b/core/services/keystore/sui.go @@ -0,0 +1,174 @@ +package keystore + +import ( + "context" + "fmt" + "github.com/smartcontractkit/chainlink-common/pkg/loop" + "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/suikey" +) + +// Sui is the interface for the Sui keystore +type Sui interface { + Get(id string) (suikey.Key, error) + GetAll() ([]suikey.Key, error) + Create(ctx context.Context) (suikey.Key, error) + Add(ctx context.Context, key suikey.Key) error + Delete(ctx context.Context, id string) (suikey.Key, error) + Import(ctx context.Context, keyJSON []byte, password string) (suikey.Key, error) + Export(id string, password string) ([]byte, error) + EnsureKey(ctx context.Context) error + Sign(ctx context.Context, id string, msg []byte) ([]byte, error) +} + +type sui struct { + *keyManager +} + +var _ Sui = &sui{} + +func newSuiKeyStore(km *keyManager) *sui { + return &sui{ + keyManager: km, + } +} + +func (ks *sui) Get(id string) (suikey.Key, error) { + ks.lock.RLock() + defer ks.lock.RUnlock() + if ks.isLocked() { + return suikey.Key{}, ErrLocked + } + return ks.getByID(id) +} + +func (ks *sui) GetAll() ([]suikey.Key, error) { + ks.lock.RLock() + defer ks.lock.RUnlock() + if ks.isLocked() { + return nil, ErrLocked + } + var accounts []suikey.Key + for _, key := range ks.keyRing.Sui { + accounts = append(accounts, key) + } + return accounts, nil +} + +func (ks *sui) Create(ctx context.Context) (suikey.Key, error) { + ks.lock.Lock() + defer ks.lock.Unlock() + if ks.isLocked() { + return suikey.Key{}, ErrLocked + } + key, err := suikey.New() + if err != nil { + return suikey.Key{}, err + } + return key, ks.safeAddKey(ctx, key) +} + +func (ks *sui) Add(ctx context.Context, key suikey.Key) error { + ks.lock.Lock() + defer ks.lock.Unlock() + if ks.isLocked() { + return ErrLocked + } + if _, found := ks.keyRing.Sui[key.ID()]; found { + return fmt.Errorf("key with ID %s already exists", key.ID()) + } + return ks.safeAddKey(ctx, &key) +} + +func (ks *sui) Delete(ctx context.Context, id string) (suikey.Key, error) { + ks.lock.Lock() + defer ks.lock.Unlock() + if ks.isLocked() { + return suikey.Key{}, ErrLocked + } + key, err := ks.getByID(id) + if err != nil { + return suikey.Key{}, err + } + err = ks.safeRemoveKey(ctx, &key) + return key, err +} + +func (ks *sui) Import(ctx context.Context, keyJSON []byte, password string) (suikey.Key, error) { + ks.lock.Lock() + defer ks.lock.Unlock() + if ks.isLocked() { + return suikey.Key{}, ErrLocked + } + key, err := suikey.FromEncryptedJSON(keyJSON, password) + if err != nil { + return suikey.Key{}, err + } + err = ks.safeAddKey(ctx, key) + return key, err +} + +func (ks *sui) 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 *sui) EnsureKey(ctx context.Context) error { + ks.lock.Lock() + defer ks.lock.Unlock() + if ks.isLocked() { + return ErrLocked + } + if len(ks.keyRing.Sui) > 0 { + return nil + } + _, err := ks.Create(ctx) + return err +} + +func (ks *sui) Sign(_ context.Context, id string, msg []byte) ([]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.Sign(msg) +} + +func (ks *sui) getByID(id string) (suikey.Key, error) { + key, found := ks.keyRing.Sui[id] + if !found { + return suikey.Key{}, KeyNotFoundError{ID: id, KeyType: "Sui"} + } + return key, nil +} + +// TODO: the approach below is deprecated, replace it +type SuiLoopSinger struct { + Sui +} + +var _ loop.Keystore = &SuiLoopSinger{} + +// Returns a list of Sui Public Keys +func (s *SuiLoopSinger) Accounts(ctx context.Context) (accounts []string, err error) { + ks, err := s.GetAll() + if err != nil { + return nil, err + } + for _, k := range ks { + accounts = append(accounts, k.ID()) + } + return +} diff --git a/core/services/keystore/sui_test.go b/core/services/keystore/sui_test.go new file mode 100644 index 00000000000..8453a1d81fd --- /dev/null +++ b/core/services/keystore/sui_test.go @@ -0,0 +1,138 @@ +package keystore_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-common/pkg/utils" + "github.com/smartcontractkit/chainlink/v2/core/internal/cltest" + "github.com/smartcontractkit/chainlink/v2/core/internal/testutils" + "github.com/smartcontractkit/chainlink/v2/core/internal/testutils/pgtest" + "github.com/smartcontractkit/chainlink/v2/core/services/keystore" + "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/suikey" +) + +func Test_SuiKeyStore_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.Sui() + 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) + requireEqualKeys(t, key, retrievedKey) + }) + + t.Run("imports and exports a key", func(t *testing.T) { + defer reset() + ctx := testutils.Context(t) + key, err := ks.Create(ctx) + require.NoError(t, err) + exportJSON, err := ks.Export(key.ID(), cltest.Password) + require.NoError(t, err) + _, err = ks.Export("non-existent", cltest.Password) + assert.Error(t, err) + _, err = ks.Delete(ctx, key.ID()) + require.NoError(t, err) + _, err = ks.Get(key.ID()) + require.Error(t, err) + importedKey, err := ks.Import(ctx, exportJSON, cltest.Password) + require.NoError(t, err) + _, err = ks.Import(ctx, exportJSON, cltest.Password) + assert.Error(t, err) + _, err = ks.Import(ctx, []byte(""), cltest.Password) + assert.Error(t, err) + require.Equal(t, key.ID(), importedKey.ID()) + retrievedKey, err := ks.Get(key.ID()) + require.NoError(t, err) + requireEqualKeys(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 := suikey.New() + require.NoError(t, err) + err = ks.Add(ctx, newKey) + require.NoError(t, err) + err = ks.Add(ctx, newKey) + assert.Error(t, err) + keys, err := ks.GetAll() + require.NoError(t, err) + require.Len(t, keys, 1) + _, err = ks.Delete(ctx, newKey.ID()) + require.NoError(t, err) + _, err = ks.Delete(ctx, newKey.ID()) + assert.Error(t, err) + keys, err = ks.GetAll() + require.NoError(t, err) + require.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) + assert.NoError(t, err) + + err = ks.EnsureKey(ctx) + assert.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 := suikey.New() + require.NoError(t, err) + require.NoError(t, ks.Add(ctx, newKey)) + + // sign unknown ID + _, err = ks.Sign(testutils.Context(t), "not-real", nil) + assert.Error(t, err) + + // sign known key + payload := []byte{1} + sig, err := ks.Sign(testutils.Context(t), newKey.ID(), payload) + require.NoError(t, err) + + directSig, err := newKey.Sign(payload) + require.NoError(t, err) + + // signatures should match using keystore sign or key sign + assert.Equal(t, directSig, sig) + }) +} diff --git a/core/services/relay/relay.go b/core/services/relay/relay.go index 8a6a12e30e3..abbd00a7229 100644 --- a/core/services/relay/relay.go +++ b/core/services/relay/relay.go @@ -15,6 +15,7 @@ const ( NetworkStarkNet = "starknet" NetworkAptos = "aptos" NetworkTron = "tron" + NetworkSui = "sui" NetworkDummy = "dummy" ) @@ -26,6 +27,7 @@ var SupportedNetworks = map[string]struct{}{ NetworkStarkNet: {}, NetworkAptos: {}, NetworkTron: {}, + NetworkSui: {}, NetworkDummy: {}, } diff --git a/core/web/presenters/sui_key.go b/core/web/presenters/sui_key.go new file mode 100644 index 00000000000..74fa69e8761 --- /dev/null +++ b/core/web/presenters/sui_key.go @@ -0,0 +1,34 @@ +package presenters + +import "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/suikey" + +// SuiKeyResource represents a Sui key JSONAPI resource. +type SuiKeyResource struct { + JAID + Account string `json:"account"` + PubKey string `json:"publicKey"` +} + +// GetName implements the api2go EntityNamer interface +func (SuiKeyResource) GetName() string { + return "encryptedSuiKeys" +} + +func NewSuiKeyResource(key suikey.Key) *SuiKeyResource { + r := &SuiKeyResource{ + JAID: JAID{ID: key.ID()}, + Account: key.Account(), + PubKey: key.PublicKeyStr(), + } + + return r +} + +func NewSuiKeyResources(keys []suikey.Key) []SuiKeyResource { + var rs []SuiKeyResource + for _, key := range keys { + rs = append(rs, *NewSuiKeyResource(key)) + } + + return rs +} diff --git a/core/web/resolver/resolver_test.go b/core/web/resolver/resolver_test.go index aaf28d3d052..cb1249643a4 100644 --- a/core/web/resolver/resolver_test.go +++ b/core/web/resolver/resolver_test.go @@ -52,6 +52,7 @@ type mocks struct { vrf *keystoreMocks.VRF solana *keystoreMocks.Solana aptos *keystoreMocks.Aptos + sui *keystoreMocks.Sui cosmos *keystoreMocks.Cosmos starknet *keystoreMocks.StarkNet tron *keystoreMocks.Tron @@ -111,6 +112,7 @@ func setupFramework(t *testing.T) *gqlTestFramework { vrf: keystoreMocks.NewVRF(t), solana: keystoreMocks.NewSolana(t), aptos: keystoreMocks.NewAptos(t), + sui: keystoreMocks.NewSui(t), cosmos: keystoreMocks.NewCosmos(t), starknet: keystoreMocks.NewStarkNet(t), tron: keystoreMocks.NewTron(t), diff --git a/core/web/resolver/sui_key.go b/core/web/resolver/sui_key.go new file mode 100644 index 00000000000..bd426c01e14 --- /dev/null +++ b/core/web/resolver/sui_key.go @@ -0,0 +1,47 @@ +package resolver + +import ( + "github.com/graph-gophers/graphql-go" + + "github.com/smartcontractkit/chainlink/v2/core/services/keystore/keys/suikey" +) + +type SuiKeyResolver struct { + key suikey.Key +} + +func NewSuiKey(key suikey.Key) *SuiKeyResolver { + return &SuiKeyResolver{key: key} +} + +func NewSuiKeys(keys []suikey.Key) []*SuiKeyResolver { + var resolvers []*SuiKeyResolver + + for _, k := range keys { + resolvers = append(resolvers, NewSuiKey(k)) + } + + return resolvers +} + +func (r *SuiKeyResolver) ID() graphql.ID { + return graphql.ID(r.key.PublicKeyStr()) +} + +func (r *SuiKeyResolver) Account() string { + return r.key.Account() +} + +// -- GetSuiKeys Query -- + +type SuiKeysPayloadResolver struct { + keys []suikey.Key +} + +func NewSuiKeysPayload(keys []suikey.Key) *SuiKeysPayloadResolver { + return &SuiKeysPayloadResolver{keys: keys} +} + +func (r *SuiKeysPayloadResolver) Results() []*SuiKeyResolver { + return NewSuiKeys(r.keys) +} diff --git a/core/web/resolver/sui_key_test.go b/core/web/resolver/sui_key_test.go new file mode 100644 index 00000000000..0e83ee5b66f --- /dev/null +++ b/core/web/resolver/sui_key_test.go @@ -0,0 +1,76 @@ +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/suikey" +) + +func TestResolver_SuiKeys(t *testing.T) { + t.Parallel() + + query := ` + query GetSuiKeys { + suiKeys { + results { + id + account + } + } + }` + k := suikey.MustNewInsecure(keystest.NewRandReaderFromSeed(1)) + result := fmt.Sprintf(` + { + "suiKeys": { + "results": [ + { + "id": "%s", + "account": "%s" + } + ] + } + }`, k.PublicKeyStr(), k.Account()) + gError := errors.New("error") + + testCases := []GQLTestCase{ + unauthorizedTestCase(GQLTestCase{query: query}, "aptosKeys"), + { + name: "success", + authenticated: true, + before: func(ctx context.Context, f *gqlTestFramework) { + f.Mocks.sui.On("GetAll").Return([]suikey.Key{k}, nil) + f.Mocks.keystore.On("Sui").Return(f.Mocks.sui) + 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.sui.On("GetAll").Return([]suikey.Key{}, gError) + f.Mocks.keystore.On("Sui").Return(f.Mocks.sui) + f.App.On("GetKeyStore").Return(f.Mocks.keystore) + }, + query: query, + result: `null`, + errors: []*gqlerrors.QueryError{ + { + Extensions: nil, + ResolverError: gError, + Path: []interface{}{"suiKeys"}, + Message: gError.Error(), + }, + }, + }, + } + + RunGQLTests(t, testCases) +} diff --git a/core/web/router.go b/core/web/router.go index 1ea70b08daa..3fce3fe66bb 100644 --- a/core/web/router.go +++ b/core/web/router.go @@ -352,6 +352,7 @@ func v2Routes(app chainlink.Application, r *gin.RouterGroup) { {"starknet", NewStarkNetKeysController(app)}, {"aptos", NewAptosKeysController(app)}, {"tron", NewTronKeysController(app)}, + {"sui", NewSuiKeysController(app)}, } { authv2.GET("/keys/"+keys.path, keys.kc.Index) authv2.POST("/keys/"+keys.path, auth.RequiresEditRole(keys.kc.Create)) diff --git a/core/web/sui_keys_controller.go b/core/web/sui_keys_controller.go new file mode 100644 index 00000000000..19e6860fbe6 --- /dev/null +++ b/core/web/sui_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/suikey" + "github.com/smartcontractkit/chainlink/v2/core/web/presenters" +) + +func NewSuiKeysController(app chainlink.Application) KeysController { + return NewKeysController[suikey.Key, presenters.SuiKeyResource](app.GetKeyStore().Sui(), app.GetLogger(), app.GetAuditLogger(), + "suiKey", presenters.NewSuiKeyResource, presenters.NewSuiKeyResources) +} diff --git a/core/web/sui_keys_controller_test.go b/core/web/sui_keys_controller_test.go new file mode 100644 index 00000000000..d380b3a56fd --- /dev/null +++ b/core/web/sui_keys_controller_test.go @@ -0,0 +1,108 @@ +package web_test + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-common/pkg/utils" + "github.com/smartcontractkit/chainlink/v2/core/internal/cltest" + "github.com/smartcontractkit/chainlink/v2/core/internal/testutils" + "github.com/smartcontractkit/chainlink/v2/core/services/keystore" + "github.com/smartcontractkit/chainlink/v2/core/web" + "github.com/smartcontractkit/chainlink/v2/core/web/presenters" +) + +func TestSuiKeysController_Index_HappyPath(t *testing.T) { + t.Parallel() + + client, keyStore := setupSuiKeysControllerTests(t) + keys, _ := keyStore.Sui().GetAll() + + response, cleanup := client.Get("/v2/keys/sui") + t.Cleanup(cleanup) + cltest.AssertServerResponse(t, response, http.StatusOK) + + resources := []presenters.SuiKeyResource{} + err := web.ParseJSONAPIResponse(cltest.ParseResponseBody(t, response), &resources) + assert.NoError(t, err) + + require.Len(t, resources, len(keys)) + + assert.Equal(t, keys[0].ID(), resources[0].ID) + assert.Equal(t, keys[0].PublicKeyStr(), resources[0].PubKey) +} + +func TestSuiKeysController_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/sui", nil) + t.Cleanup(cleanup) + cltest.AssertServerResponse(t, response, http.StatusOK) + + keys, _ := keyStore.Sui().GetAll() + require.Len(t, keys, 1) + + resource := presenters.SuiKeyResource{} + err := web.ParseJSONAPIResponse(cltest.ParseResponseBody(t, response), &resource) + assert.NoError(t, err) + + assert.Equal(t, keys[0].ID(), resource.ID) + assert.Equal(t, keys[0].PublicKeyStr(), resource.PubKey) + + _, err = keyStore.Sui().Get(resource.ID) + require.NoError(t, err) +} + +func TestSuiKeysController_Delete_NonExistentSuiKeyID(t *testing.T) { + t.Parallel() + + client, _ := setupSuiKeysControllerTests(t) + + nonExistentSuiKeyID := "foobar" + response, cleanup := client.Delete("/v2/keys/sui/" + nonExistentSuiKeyID) + t.Cleanup(cleanup) + assert.Equal(t, http.StatusNotFound, response.StatusCode) +} + +func TestSuiKeysController_Delete_HappyPath(t *testing.T) { + t.Parallel() + ctx := testutils.Context(t) + + client, keyStore := setupSuiKeysControllerTests(t) + + keys, _ := keyStore.Sui().GetAll() + initialLength := len(keys) + key, _ := keyStore.Sui().Create(ctx) + + response, cleanup := client.Delete("/v2/keys/sui/" + key.ID()) + t.Cleanup(cleanup) + assert.Equal(t, http.StatusOK, response.StatusCode) + assert.Error(t, utils.JustError(keyStore.Sui().Get(key.ID()))) + + keys, _ = keyStore.Sui().GetAll() + assert.Len(t, keys, initialLength) +} + +func setupSuiKeysControllerTests(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)) + suiKeyStore := app.GetKeyStore().Sui() + require.NotNil(t, suiKeyStore) + require.NoError(t, suiKeyStore.Add(ctx, cltest.DefaultSuiKey)) + + client := app.NewHTTPClient(nil) + + return client, app.GetKeyStore() +} diff --git a/deployment/ccip/changeset/testhelpers/test_environment.go b/deployment/ccip/changeset/testhelpers/test_environment.go index cc87c4880cd..2b39225e4b6 100644 --- a/deployment/ccip/changeset/testhelpers/test_environment.go +++ b/deployment/ccip/changeset/testhelpers/test_environment.go @@ -20,6 +20,8 @@ import ( "github.com/smartcontractkit/chainlink-common/pkg/logger" "github.com/smartcontractkit/chainlink-testing-framework/lib/utils/testcontext" + "github.com/smartcontractkit/chainlink-deployments-framework/chain" + suichain "github.com/smartcontractkit/chainlink-deployments-framework/chain/sui" cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" "github.com/smartcontractkit/chainlink/deployment/ccip/changeset" @@ -79,6 +81,7 @@ type TestConfigs struct { Chains int // only used in memory mode, for docker mode, this is determined by the integration-test config toml input SolChains int // only used in memory mode, for docker mode, this is determined by the integration-test config toml input AptosChains int // only used in memory mode, for docker mode, this is determined by the integration-test config toml input + SuiChains int // only used in memory mode, for docker mode, this is determined by the integration-test config toml input ChainIDs []uint64 // only used in memory mode, for docker mode, this is determined by the integration-test config toml input NumOfUsersPerChain int // only used in memory mode, for docker mode, this is determined by the integration-test config toml input Nodes int // only used in memory mode, for docker mode, this is determined by the integration-test config toml input @@ -271,6 +274,12 @@ func WithAptosChains(numChains int) TestOps { } } +func WithSuiChains(numChains int) TestOps { + return func(testCfg *TestConfigs) { + testCfg.SuiChains = numChains + } +} + func WithNumOfUsersPerChain(numUsers int) TestOps { return func(testCfg *TestConfigs) { testCfg.NumOfUsersPerChain = numUsers @@ -337,6 +346,7 @@ type MemoryEnvironment struct { Chains map[uint64]cldf.Chain SolChains map[uint64]cldf.SolChain AptosChains map[uint64]cldf.AptosChain + SuiChains map[uint64]suichain.Chain } func (m *MemoryEnvironment) TestConfigs() *TestConfigs { @@ -374,10 +384,16 @@ func (m *MemoryEnvironment) StartChains(t *testing.T) { m.Chains = chains m.SolChains = memory.NewMemoryChainsSol(t, tc.SolChains) m.AptosChains = memory.NewMemoryChainsAptos(t, tc.AptosChains) + suiChains := memory.NewMemoryChainsSui(t, tc.SuiChains) + blockChains := map[uint64]chain.BlockChain{} + for _, c := range suiChains { + blockChains[c.Selector] = c + } env := cldf.Environment{ Chains: m.Chains, SolChains: m.SolChains, AptosChains: m.AptosChains, + BlockChains: chain.NewBlockChains(blockChains), } homeChainSel, feedSel := allocateCCIPChainSelectors(chains) replayBlocks, err := LatestBlocksByChain(ctx, env) @@ -400,6 +416,7 @@ func (m *MemoryEnvironment) StartNodes(t *testing.T, crConfig deployment.Capabil Chains: m.Chains, SolChains: m.SolChains, AptosChains: m.AptosChains, + SuiChains: m.SuiChains, NumNodes: tc.Nodes, NumBootstraps: tc.Bootstraps, RegistryConfig: crConfig, @@ -415,7 +432,7 @@ func (m *MemoryEnvironment) StartNodes(t *testing.T, crConfig deployment.Capabil }) } m.nodes = nodes - m.DeployedEnv.Env = memory.NewMemoryEnvironmentFromChainsNodes(func() context.Context { return ctx }, lggr, m.Chains, m.SolChains, m.AptosChains, nodes) + m.DeployedEnv.Env = memory.NewMemoryEnvironmentFromChainsNodes(func() context.Context { return ctx }, lggr, m.Chains, m.SolChains, m.AptosChains, m.SuiChains, nodes) } func (m *MemoryEnvironment) DeleteJobs(ctx context.Context, jobIDs map[string][]string) error { diff --git a/deployment/environment/devenv/chain.go b/deployment/environment/devenv/chain.go index 5dff31f55f5..690d064967f 100644 --- a/deployment/environment/devenv/chain.go +++ b/deployment/environment/devenv/chain.go @@ -2,6 +2,7 @@ package devenv import ( "context" + "crypto/ed25519" "encoding/json" "errors" "fmt" @@ -24,16 +25,20 @@ import ( "github.com/smartcontractkit/chainlink-common/pkg/logger" + suichain "github.com/smartcontractkit/chainlink-deployments-framework/chain/sui" cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" "github.com/gagliardetto/solana-go" + "github.com/pattonkan/sui-go/suiclient" + "github.com/smartcontractkit/chainlink/deployment" ) const ( EVMChainType = "EVM" SolChainType = "SOLANA" + SuiChainType = "SUI" ) type CribRPCs struct { @@ -136,11 +141,14 @@ func (c *ChainConfig) ToRPCs() []cldf.RPC { return rpcs } -func NewChains(logger logger.Logger, configs []ChainConfig) (map[uint64]cldf.Chain, map[uint64]cldf.SolChain, error) { +func NewChains(logger logger.Logger, configs []ChainConfig) (map[uint64]cldf.Chain, map[uint64]cldf.SolChain, map[uint64]suichain.Chain, error) { evmChains := make(map[uint64]cldf.Chain) solChains := make(map[uint64]cldf.SolChain) + suiChains := make(map[uint64]suichain.Chain) + var evmSyncMap sync.Map var solSyncMap sync.Map + var suiSyncMap sync.Map g := new(errgroup.Group) for _, chainCfg := range configs { @@ -238,6 +246,23 @@ func NewChains(logger logger.Logger, configs []ChainConfig) (map[uint64]cldf.Cha }) return nil + case SuiChainType: + _, privateKey, err := ed25519.GenerateKey(nil) + if err != nil { + return err + } + client := suiclient.NewClient(chainCfg.HTTPRPCs[0].External) + suiSyncMap.Store(chainDetails.ChainSelector, suichain.Chain{ + Selector: chainDetails.ChainSelector, + Client: client, + DeployerKey: privateKey, + URL: chainCfg.HTTPRPCs[0].External, + Confirm: func(txHash string, opts ...any) error { + return errors.New("TODO sui Confirm") + }, + }) + return nil + default: return fmt.Errorf("chain type %s is not supported", chainCfg.ChainType) } @@ -245,7 +270,7 @@ func NewChains(logger logger.Logger, configs []ChainConfig) (map[uint64]cldf.Cha } if err := g.Wait(); err != nil { - return nil, nil, err + return nil, nil, nil, err } evmSyncMap.Range(func(sel, value interface{}) bool { @@ -258,7 +283,12 @@ func NewChains(logger logger.Logger, configs []ChainConfig) (map[uint64]cldf.Cha return true }) - return evmChains, solChains, nil + suiSyncMap.Range(func(sel, value interface{}) bool { + suiChains[sel.(uint64)] = value.(suichain.Chain) + return true + }) + + return evmChains, solChains, suiChains, nil } func (c *ChainConfig) SetSolDeployerKey(keyString *string) error { diff --git a/deployment/environment/devenv/environment.go b/deployment/environment/devenv/environment.go index c87fc52f743..59bb2ee5f0c 100644 --- a/deployment/environment/devenv/environment.go +++ b/deployment/environment/devenv/environment.go @@ -22,7 +22,7 @@ type EnvironmentConfig struct { } func NewEnvironment(ctx func() context.Context, lggr logger.Logger, config EnvironmentConfig) (*cldf.Environment, *DON, error) { - chains, solChains, err := NewChains(lggr, config.Chains) + chains, solChains, suiChains, err := NewChains(lggr, config.Chains) if err != nil { return nil, nil, fmt.Errorf("failed to create chains: %w", err) } @@ -59,6 +59,9 @@ func NewEnvironment(ctx func() context.Context, lggr logger.Logger, config Envir for _, c := range solChains { blockChains[c.Selector] = c } + for _, c := range suiChains { + blockChains[c.Selector] = c + } return cldf.NewCLDFEnvironment( DevEnv, diff --git a/deployment/environment/memory/environment.go b/deployment/environment/memory/environment.go index 15850aee1a5..3d9645829ce 100644 --- a/deployment/environment/memory/environment.go +++ b/deployment/environment/memory/environment.go @@ -20,6 +20,7 @@ import ( chainsel "github.com/smartcontractkit/chain-selectors" "github.com/smartcontractkit/chainlink-deployments-framework/chain" + suichain "github.com/smartcontractkit/chainlink-deployments-framework/chain/sui" "github.com/smartcontractkit/chainlink-deployments-framework/datastore" cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" @@ -53,6 +54,7 @@ type MemoryEnvironmentConfig struct { Chains int SolChains int AptosChains int + SuiChains int ZkChains int NumOfUsersPerChain int Nodes int @@ -69,6 +71,7 @@ type NewNodesConfig struct { SolChains map[uint64]cldf.SolChain // Aptos chains to be configured. Optional. AptosChains map[uint64]cldf.AptosChain + SuiChains map[uint64]suichain.Chain NumNodes int NumBootstraps int RegistryConfig deployment.CapabilityRegistryConfig @@ -110,6 +113,10 @@ func NewMemoryChainsAptos(t *testing.T, numChains int) map[uint64]cldf.AptosChai return GenerateChainsAptos(t, numChains) } +func NewMemoryChainsSui(t *testing.T, numChains int) map[uint64]suichain.Chain { + return GenerateChainsSui(t, numChains) +} + func NewMemoryChainsZk(t *testing.T, numChains int) map[uint64]cldf.Chain { return GenerateChainsZk(t, numChains) } @@ -208,6 +215,7 @@ func NewNodes( Chains: cfg.Chains, Solchains: cfg.SolChains, Aptoschains: cfg.AptosChains, + Suichains: cfg.SuiChains, LogLevel: cfg.LogLevel, Bootstrap: true, RegistryConfig: cfg.RegistryConfig, @@ -223,6 +231,7 @@ func NewNodes( Chains: cfg.Chains, Solchains: cfg.SolChains, Aptoschains: cfg.AptosChains, + Suichains: cfg.SuiChains, LogLevel: cfg.LogLevel, Bootstrap: false, RegistryConfig: cfg.RegistryConfig, @@ -242,6 +251,7 @@ func NewMemoryEnvironmentFromChainsNodes( chains map[uint64]cldf.Chain, solChains map[uint64]cldf.SolChain, aptosChains map[uint64]cldf.AptosChain, + suiChains map[uint64]suichain.Chain, nodes map[string]Node, ) cldf.Environment { var nodeIDs []string @@ -259,6 +269,9 @@ func NewMemoryEnvironmentFromChainsNodes( for _, c := range aptosChains { blockChains[c.Selector] = c } + for _, c := range suiChains { + blockChains[c.Selector] = c + } return *cldf.NewCLDFEnvironment( Memory, @@ -284,6 +297,7 @@ func NewMemoryEnvironment(t *testing.T, lggr logger.Logger, logLevel zapcore.Lev chains, _ := NewMemoryChains(t, config.Chains, config.NumOfUsersPerChain) solChains := NewMemoryChainsSol(t, config.SolChains) aptosChains := NewMemoryChainsAptos(t, config.AptosChains) + suiChains := NewMemoryChainsSui(t, config.SuiChains) zkChains := NewMemoryChainsZk(t, config.ZkChains) for chainSel, chain := range zkChains { chains[chainSel] = chain @@ -293,6 +307,7 @@ func NewMemoryEnvironment(t *testing.T, lggr logger.Logger, logLevel zapcore.Lev Chains: chains, SolChains: solChains, AptosChains: aptosChains, + SuiChains: suiChains, NumNodes: config.Nodes, NumBootstraps: config.Bootstraps, RegistryConfig: config.RegistryConfig, @@ -318,6 +333,9 @@ func NewMemoryEnvironment(t *testing.T, lggr logger.Logger, logLevel zapcore.Lev for _, c := range aptosChains { blockChains[c.Selector] = c } + for _, c := range suiChains { + blockChains[c.Selector] = c + } return *cldf.NewCLDFEnvironment( Memory, lggr, diff --git a/deployment/environment/memory/job_service_client_test.go b/deployment/environment/memory/job_service_client_test.go index c14fb45e642..ee4ce5bae98 100644 --- a/deployment/environment/memory/job_service_client_test.go +++ b/deployment/environment/memory/job_service_client_test.go @@ -28,6 +28,7 @@ func TestJobClientProposeJob(t *testing.T) { Chains: chains, Solchains: nil, Aptoschains: nil, + Suichains: nil, LogLevel: zapcore.DebugLevel, Bootstrap: false, RegistryConfig: deployment.CapabilityRegistryConfig{}, @@ -131,6 +132,7 @@ func TestJobClientJobAPI(t *testing.T) { Chains: chains, Solchains: nil, Aptoschains: nil, + Suichains: nil, LogLevel: zapcore.DebugLevel, Bootstrap: false, RegistryConfig: deployment.CapabilityRegistryConfig{}, diff --git a/deployment/environment/memory/node.go b/deployment/environment/memory/node.go index ebea8fb9d9f..ae8d8d7aa23 100644 --- a/deployment/environment/memory/node.go +++ b/deployment/environment/memory/node.go @@ -36,6 +36,7 @@ import ( solcfg "github.com/smartcontractkit/chainlink-solana/pkg/solana/config" + suichain "github.com/smartcontractkit/chainlink-deployments-framework/chain/sui" cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" "github.com/smartcontractkit/chainlink/deployment" @@ -236,6 +237,7 @@ type NewNodeConfig struct { Solchains map[uint64]cldf.SolChain // Aptos chains to be configured. Optional. Aptoschains map[uint64]cldf.AptosChain + Suichains map[uint64]suichain.Chain LogLevel zapcore.Level Bootstrap bool RegistryConfig deployment.CapabilityRegistryConfig @@ -330,6 +332,16 @@ func NewNode( } c.Aptos = aptosConfigs + var suiConfigs chainlink.RawConfigs + for chainID, chain := range nodecfg.Suichains { + suiChainID, err := chainsel.GetChainIDFromSelector(chainID) + if err != nil { + t.Fatal(err) + } + suiConfigs = append(suiConfigs, createSuiChainConfig(suiChainID, chain)) + } + c.Sui = suiConfigs + for _, opt := range configOpts { opt(c) } @@ -390,7 +402,7 @@ func NewNode( RetirementReportCache: retirement.NewRetirementReportCache(lggr, db), }) require.NoError(t, err) - keys := CreateKeys(t, app, nodecfg.Chains, nodecfg.Solchains, nodecfg.Aptoschains) + keys := CreateKeys(t, app, nodecfg.Chains, nodecfg.Solchains, nodecfg.Aptoschains, nodecfg.Suichains) nodeLabels := make([]*ptypes.Label, 1) if nodecfg.Bootstrap { @@ -416,6 +428,7 @@ func NewNode( maps.Keys(nodecfg.Chains), maps.Keys(nodecfg.Solchains), maps.Keys(nodecfg.Aptoschains), + maps.Keys(nodecfg.Suichains), ), Keys: keys, Addr: net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: nodecfg.Port}, @@ -437,6 +450,7 @@ func CreateKeys(t *testing.T, chains map[uint64]cldf.Chain, solchains map[uint64]cldf.SolChain, aptoschains map[uint64]cldf.AptosChain, + suichains map[uint64]suichain.Chain, ) Keys { ctx := t.Context() _, err := app.GetKeyStore().P2P().Create(ctx) @@ -470,6 +484,8 @@ func CreateKeys(t *testing.T, ctype = chaintype.Cosmos case chainsel.FamilyAptos: ctype = chaintype.Aptos + case chainsel.FamilySui: + ctype = chaintype.Sui default: panic(fmt.Sprintf("Unsupported chain family %v", family)) } @@ -524,6 +540,19 @@ func CreateKeys(t *testing.T, transmitter := keys[0] transmitters[chain.Selector] = transmitter.ID() + // TODO: funding + case chainsel.FamilySui: + keystore := app.GetKeyStore().Sui() + err = keystore.EnsureKey(ctx) + require.NoError(t, err, "failed to create key for sui") + + keys, err := keystore.GetAll() + require.NoError(t, err) + require.Len(t, keys, 1) + + transmitter := keys[0] + transmitters[chain.Selector] = transmitter.ID() + // TODO: funding case chainsel.FamilyStarknet: keystore := app.GetKeyStore().StarkNet() diff --git a/deployment/environment/memory/node_test.go b/deployment/environment/memory/node_test.go index 854d71f063f..2553793927b 100644 --- a/deployment/environment/memory/node_test.go +++ b/deployment/environment/memory/node_test.go @@ -22,6 +22,7 @@ func TestNode(t *testing.T) { Chains: chains, Solchains: nil, Aptoschains: nil, + Suichains: nil, LogLevel: zapcore.DebugLevel, Bootstrap: false, RegistryConfig: deployment.CapabilityRegistryConfig{}, diff --git a/deployment/environment/memory/sui_chains.go b/deployment/environment/memory/sui_chains.go new file mode 100644 index 00000000000..f32f2b06c04 --- /dev/null +++ b/deployment/environment/memory/sui_chains.go @@ -0,0 +1,140 @@ +package memory + +import ( + "context" + "crypto/ed25519" + "errors" + "testing" + "time" + + "github.com/pattonkan/sui-go/suiclient" + "github.com/pattonkan/sui-go/suisigner" + + "github.com/smartcontractkit/freeport" + + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go" + + chainsel "github.com/smartcontractkit/chain-selectors" + + "github.com/smartcontractkit/chainlink-testing-framework/framework" + "github.com/smartcontractkit/chainlink-testing-framework/framework/components/blockchain" + + suichain "github.com/smartcontractkit/chainlink-deployments-framework/chain/sui" + + "github.com/smartcontractkit/chainlink/v2/core/services/chainlink" +) + +func getTestSuiChainSelectors() []uint64 { + // TODO: CTF to support different chain ids, need to investigate if it's possible (thru node config.yaml?) + return []uint64{chainsel.SUI_LOCALNET.Selector} +} + +func GenerateChainsSui(t *testing.T, numChains int) map[uint64]suichain.Chain { + testSuiChainSelectors := getTestSuiChainSelectors() + if len(testSuiChainSelectors) < numChains { + t.Fatalf("not enough test sui chain selectors available") + } + chains := make(map[uint64]suichain.Chain) + for i := 0; i < numChains; i++ { + selector := testSuiChainSelectors[i] + chainID, err := chainsel.GetChainIDFromSelector(selector) + require.NoError(t, err) + + url, _, privateKey, client := suiChain(t, chainID) + chains[selector] = suichain.Chain{ + Selector: selector, + Client: client, + DeployerKey: privateKey, + URL: url, + Confirm: func(txHash string, opts ...any) error { + return errors.New("TODO: sui Confirm") + }, + } + } + t.Logf("Created %d Sui chains: %+v", len(chains), chains) + return chains +} + +func suiChain(t *testing.T, chainID string) (string, string, ed25519.PrivateKey, *suiclient.ClientImpl) { + t.Helper() + + // initialize the docker network used by CTF + err := framework.DefaultNetwork(once) + require.NoError(t, err) + + maxRetries := 10 + var url string + var suiAddress string + var mnemonic string + for i := 0; i < maxRetries; i++ { + // reserve all the ports we need explicitly to avoid port conflicts in other tests + ports := freeport.GetN(t, 2) + + bcInput := &blockchain.Input{ + Image: "", // filled out by defaultSui function + Type: "sui", + // TODO: this is unused, can it be applied? + ChainID: chainID, + } + output, err := blockchain.NewBlockchainNetwork(bcInput) + if err != nil { + t.Logf("Error creating Sui network: %v", err) + freeport.Return(ports) + time.Sleep(time.Second) + maxRetries -= 1 + continue + } + require.NoError(t, err) + testcontainers.CleanupContainer(t, output.Container) + url = output.Nodes[0].ExternalHTTPUrl + + suiWalletInfo := output.NetworkSpecificData.SuiAccount + mnemonic = suiWalletInfo.Mnemonic + suiAddress = suiWalletInfo.SuiAddress + break + } + + suiSigner, err := suisigner.NewSignerWithMnemonic(mnemonic, suisigner.KeySchemeFlagEd25519) + require.NoError(t, err) + suiPrivateKey := suiSigner.PrivateKey() + + client := suiclient.NewClient(url) + + var ready bool + for i := 0; i < 30; i++ { + time.Sleep(time.Second) + receivedChainID, err := client.GetChainIdentifier(context.Background()) + if err != nil { + t.Logf("API server not ready yet (attempt %d): %+v\n", i+1, err) + continue + } + // we can't compare receivedChainID to chainID because it's generated from a new genesis block + // checkpoint each time + // TODO: could we keep the same genesis config each time when starting the container? + t.Logf("Successfully fetched chain id: %s", receivedChainID) + ready = true + break + } + require.True(t, ready, "Sui network not ready") + time.Sleep(15 * time.Second) // we have slot errors that force retries if the chain is not given enough time to boot + + return url, suiAddress, suiPrivateKey, client +} + +func createSuiChainConfig(chainID string, chain suichain.Chain) chainlink.RawConfig { + chainConfig := chainlink.RawConfig{} + + chainConfig["Enabled"] = true + chainConfig["ChainID"] = chainID + chainConfig["NetworkName"] = "sui-localnet" + chainConfig["NetworkNameFull"] = "sui-localnet" + chainConfig["Nodes"] = []any{ + map[string]any{ + "Name": "primary", + "URL": chain.URL, + }, + } + + return chainConfig +} diff --git a/deployment/keystone/changeset/test/env_setup.go b/deployment/keystone/changeset/test/env_setup.go index f5d215472b4..5e79055c01e 100644 --- a/deployment/keystone/changeset/test/env_setup.go +++ b/deployment/keystone/changeset/test/env_setup.go @@ -528,7 +528,7 @@ func setupMemoryNodeTest(t *testing.T, registryChainSel uint64, chains map[uint6 dons.Put(newMemoryDon(c.AssetDonConfig.Name, assetNodes)) dons.Put(newMemoryDon(c.WriterDonConfig.Name, cwNodes)) - env := memory.NewMemoryEnvironmentFromChainsNodes(t.Context, lggr, chains, nil, nil, dons.AllNodes()) + env := memory.NewMemoryEnvironmentFromChainsNodes(t.Context, lggr, chains, nil, nil, nil, dons.AllNodes()) return dons, env } diff --git a/integration-tests/smoke/ccip/ccip_sui_dest_messaging_test.go b/integration-tests/smoke/ccip/ccip_sui_dest_messaging_test.go new file mode 100644 index 00000000000..ca7eb711060 --- /dev/null +++ b/integration-tests/smoke/ccip/ccip_sui_dest_messaging_test.go @@ -0,0 +1,92 @@ +package ccip + +import ( + "fmt" + "golang.org/x/exp/maps" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink/deployment/ccip/changeset/testhelpers" + testsetups "github.com/smartcontractkit/chainlink/integration-tests/testsetups/ccip" +) + +func Test_CCIPMessaging_EVM2Sui(t *testing.T) { + e, _, _ := testsetups.NewIntegrationEnvironment( + t, + testhelpers.WithNumOfChains(2), + testhelpers.WithSuiChains(1), + ) + + evmChainSelectors := maps.Keys(e.Env.Chains) + suiChains, err := e.Env.BlockChains.SuiChains() + require.NoError(t, err) + suiChainSelectors := maps.Keys(suiChains) + require.Equal(t, len(suiChainSelectors), 1) + + fmt.Println("EVM: ", evmChainSelectors) + fmt.Println("Sui: ", suiChainSelectors) + + /* + + // Deploy dummy receiver + t.Log("Deploying CCIPDummyReceiver...") + testhelpers.DeployAptosCCIPReceiver(t, e.Env) + + state, err := stateview.LoadOnchainState(e.Env) + require.NoError(t, err) + + sourceChain := evmChainSelectors[0] + destChain := aptosChainSelectors[0] + + t.Log("Source chain (EVM): ", sourceChain, "Dest chain (Aptos): ", destChain) + + testhelpers.AddLaneWithDefaultPricesAndFeeQuoterConfig(t, &e, state, sourceChain, destChain, false) + + var ( + replayed bool + nonce uint64 + sender = common.LeftPadBytes(e.Env.Chains[sourceChain].DeployerKey.From.Bytes(), 32) + out messagingtest.TestCaseOutput + setup = messagingtest.NewTestSetupWithDeployedEnv( + t, + e, + state, + sourceChain, + destChain, + sender, + false, // testRouter + ) + ) + + t.Run("Message to Aptos", func(t *testing.T) { + ccipChainState := state.AptosChains[destChain] + message := []byte("Hello Aptos, from EVM!") + out = messagingtest.Run(t, + messagingtest.TestCase{ + TestSetup: setup, + Replayed: replayed, + Nonce: &nonce, + ValidationType: messagingtest.ValidationTypeExec, + Receiver: ccipChainState.ReceiverAddress[:], + MsgData: message, + // true for out of order execution, which is necessary and enforced for Aptos + ExtraArgs: testhelpers.MakeEVMExtraArgsV2(100000, true), + ExpectedExecutionState: testhelpers.EXECUTION_STATE_SUCCESS, + ExtraAssertions: []func(t *testing.T){ + func(t *testing.T) { + // TODO: check dummy receiver events + // dummyReceiver := state.AptosChains[destChain].ReceiverAddress + // events, err := e.Env.AptosChains[destChain].Client.EventsByHandle(dummyReceiver, fmt.Sprintf("%s::dummy_receiver::ReceivedMessage", dummyReceiver), "received_message_events", nil, nil) + // require.NoError(t, err) + // require.Len(t, events, 1) + // var receivedMessage module_dummy_receiver.ReceivedMessage + // err = codec.DecodeAptosJsonValue(events[0].Data, &receivedMessage) + // require.NoError(t, err) + // require.Equal(t, message, receivedMessage.Data) + }, + }, + }, + ) + }) */ +} diff --git a/integration-tests/smoke/ccip/ccip_sui_source_messaging_test.go b/integration-tests/smoke/ccip/ccip_sui_source_messaging_test.go new file mode 100644 index 00000000000..b381ad5d6fe --- /dev/null +++ b/integration-tests/smoke/ccip/ccip_sui_source_messaging_test.go @@ -0,0 +1,29 @@ +package ccip + +import ( + "fmt" + "golang.org/x/exp/maps" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink/deployment/ccip/changeset/testhelpers" + testsetups "github.com/smartcontractkit/chainlink/integration-tests/testsetups/ccip" +) + +func Test_CCIPMessaging_Sui2EVM(t *testing.T) { + e, _, _ := testsetups.NewIntegrationEnvironment( + t, + testhelpers.WithNumOfChains(2), + testhelpers.WithSuiChains(1), + ) + + evmChainSelectors := maps.Keys(e.Env.Chains) + suiChains, err := e.Env.BlockChains.SuiChains() + require.NoError(t, err) + suiChainSelectors := maps.Keys(suiChains) + require.Equal(t, len(suiChainSelectors), 1) + + fmt.Println("EVM: ", evmChainSelectors) + fmt.Println("Sui: ", suiChainSelectors) +} diff --git a/integration-tests/testsetups/ccip/test_helpers.go b/integration-tests/testsetups/ccip/test_helpers.go index f94e142a08c..d0290da04ec 100644 --- a/integration-tests/testsetups/ccip/test_helpers.go +++ b/integration-tests/testsetups/ccip/test_helpers.go @@ -99,7 +99,7 @@ func (l *DeployedLocalDevEnvironment) StartChains(t *testing.T) { require.NotEmpty(t, homeChainSel, "homeChainSel should not be empty") feedSel := l.devEnvTestCfg.CCIP.GetFeedChainSelector() require.NotEmpty(t, feedSel, "feedSel should not be empty") - chains, _, err := devenv.NewChains(lggr, envConfig.Chains) + chains, _, _, err := devenv.NewChains(lggr, envConfig.Chains) require.NoError(t, err) replayBlocks, err := testhelpers.LatestBlocksByChain(ctx, l.DeployedEnv.Env) require.NoError(t, err) diff --git a/plugins/plugins.private.yaml b/plugins/plugins.private.yaml index 672238967e1..fff60df1c54 100644 --- a/plugins/plugins.private.yaml +++ b/plugins/plugins.private.yaml @@ -8,6 +8,10 @@ defaults: goflags: "-ldflags=-s" plugins: + sui: + - moduleURI: "github.com/smartcontractkit/chainlink-sui" + gitRef: "696a163e7fd3cc618b56c54e6c6f88dfb88795e3" + installPath: "github.com/smartcontractkit/chainlink-sui/relayer/cmd/chainlink-sui" cron: - moduleURI: "github.com/smartcontractkit/capabilities/cron" gitRef: "86191e815fb879118fb303bfd974a120c1945daa" @@ -26,4 +30,3 @@ plugins: moduleURI: "github.com/smartcontractkit/capabilities/workflowevent" gitRef: "86191e815fb879118fb303bfd974a120c1945daa" installPath: "github.com/smartcontractkit/capabilities/workflowevent" - diff --git a/sui-test.sh b/sui-test.sh new file mode 100755 index 00000000000..401e7527830 --- /dev/null +++ b/sui-test.sh @@ -0,0 +1,30 @@ +#!/bin/bash +set -euox pipefail + +export CL_DIR=$(dirname $0) +export SUI_DIR=$(realpath $CL_DIR/../chainlink-sui) + +export TEST_LOG_LEVEL=debug +export LOG_LEVEL=debug +export SETH_LOG_LEVEL=debug +export TEST_SETH_LOG_LEVEL=debug +export CL_DATABASE_URL="postgresql://postgres:postgres@localhost:5432/chainlink_test?sslmode=disable" +export CL_SUI_CMD="$SUI_DIR/chainlink-sui" + +rm -vf $HOME/ram/sui.txt $HOME/ram/sui-log.txt $HOME/ram/loop_* + +pushd $CL_DIR/integration-tests/smoke/ccip/logs +rm -vf *.log +popd + +pushd $SUI_DIR +rm -vf chainlink-sui +go build -o chainlink-sui ./relayer/cmd/chainlink-sui/main.go +popd + +cd integration-tests/smoke/ccip +if [ "${1:-}" = "dest" ]; then + exec go test -v -tags=integration -count=1 -run Test_CCIPMessaging_EVM2Sui ./... +else + exec go test -v -tags=integration -count=1 -run Test_CCIPMessaging_Sui2EVM ./... +fi