From 93a627c14bef994afdd93ece5cefb44d0ddeeb79 Mon Sep 17 00:00:00 2001 From: Marcin Lenczewski Date: Thu, 22 Aug 2024 14:19:48 -0400 Subject: [PATCH 1/3] Add Validator features and test cases --- Makefile | 9 +- pkg/coinbase/staking_operation.go | 144 +++++++++++++--- pkg/coinbase/staking_operation_test.go | 219 +++++++++++++++++++++++++ pkg/coinbase/staking_reward_test.go | 2 +- pkg/coinbase/utils.go | 2 + pkg/coinbase/validator.go | 66 ++++++++ pkg/{coinbase => }/mocks/AssetsAPI.go | 0 pkg/{coinbase => }/mocks/StakeAPI.go | 0 8 files changed, 417 insertions(+), 25 deletions(-) create mode 100644 pkg/coinbase/staking_operation_test.go create mode 100644 pkg/coinbase/validator.go rename pkg/{coinbase => }/mocks/AssetsAPI.go (100%) rename pkg/{coinbase => }/mocks/StakeAPI.go (100%) diff --git a/Makefile b/Makefile index 7418ebf..700c6b0 100644 --- a/Makefile +++ b/Makefile @@ -12,14 +12,19 @@ lint-fix: .PHONY: test test: - go test ./pkg/... + go test ./pkg/coinbase/... ./pkg/auth/... .PHONY: test-coverage test-coverage: - go test -coverprofile=coverage.out ./pkg/... + go test -coverprofile=coverage.out ./pkg/coinbase/... ./pkg/auth/... go tool cover -html=coverage.out open cover.html +.PHONY: mocks +mocks: + mockery --disable-version-string --name StakeAPI --keeptree --dir gen/client --output pkg/mocks + mockery --disable-version-string --name AssetsAPI --keeptree --dir gen/client --output pkg/mocks + .PHONY: docs docs: go doc -all diff --git a/pkg/coinbase/staking_operation.go b/pkg/coinbase/staking_operation.go index 8cf28ce..0524337 100644 --- a/pkg/coinbase/staking_operation.go +++ b/pkg/coinbase/staking_operation.go @@ -3,8 +3,10 @@ package coinbase import ( "context" "crypto/ecdsa" + "encoding/base64" "fmt" "math/big" + "time" "github.com/coinbase/coinbase-sdk-go/gen/client" ) @@ -19,6 +21,12 @@ func WithStakingOperationMode(mode string) StakingOperationOption { return WithStakingOperationOption("mode", mode) } +// WithStakingOperationImmediate allows for the setting of the immediate flag +// specifically for Dedicate ETH Staking whether to immediate unstake or not. (i.e. `true` or `false`) +func WithStakingOperationImmediate(immediate string) StakingOperationOption { + return WithStakingOperationOption("immediate", immediate) +} + // WithStakingOperationOption allows for the passing of custom options // to the staking operation, like `mode` or `withdrawal_address`. func WithStakingOperationOption(optionKey string, optionValue string) StakingOperationOption { @@ -27,6 +35,30 @@ func WithStakingOperationOption(optionKey string, optionValue string) StakingOpe } } +type waitOptions struct { + intervalSeconds int + timeoutSeconds int +} + +// WaitOption allows for the passing of custom options to the wait function. +type WaitOption func(*waitOptions) + +// WithWaitIntervalSeconds sets the interval in seconds to wait between +// polling the staking operation. +func WithWaitIntervalSeconds(intervalSeconds int) WaitOption { + return func(o *waitOptions) { + o.intervalSeconds = intervalSeconds + } +} + +// WithWaitTimeoutSeconds sets the timeout in seconds to wait for the +// staking operation to complete. +func WithWaitTimeoutSeconds(timeoutSeconds int) WaitOption { + return func(o *waitOptions) { + o.timeoutSeconds = timeoutSeconds + } +} + // BuildStakingOperation will build an ephemeral staking operation based on // the passed address, assetID, action, and amount. func (c *Client) BuildStakingOperation( @@ -99,16 +131,6 @@ func (c *Client) BuildClaimStakeOperation( return c.BuildStakingOperation(ctx, address, assetID, "claim_stake", amount, o...) } -// FetchExternalStakingOperation loads a staking operation from the API associated -// with an address. -func (c *Client) FetchExternalStakingOperation(ctx context.Context, address *Address, id string) (*StakingOperation, error) { - op, _, err := c.client.StakeAPI.GetExternalStakingOperation(ctx, address.NetworkID(), address.ID(), id).Execute() - if err != nil { - return nil, err - } - return newStakingOperationFromModel(op) -} - // StakingOperation represents a staking operation for // a given action, asset, and amount. type StakingOperation struct { @@ -117,36 +139,36 @@ type StakingOperation struct { } // ID returns the StakingOperation ID -func (o *StakingOperation) ID() string { - return o.model.Id +func (s *StakingOperation) ID() string { + return s.model.Id } // NetworkID returns the StakingOperation network id -func (o *StakingOperation) NetworkID() string { - return o.model.NetworkId +func (s *StakingOperation) NetworkID() string { + return s.model.NetworkId } // AddressID returns the StakingOperation address id -func (o *StakingOperation) AddressID() string { - return o.model.AddressId +func (s *StakingOperation) AddressID() string { + return s.model.AddressId } // Status returns the StakingOperation status -func (o *StakingOperation) Status() string { - return o.model.Status +func (s *StakingOperation) Status() string { + return s.model.Status } // Transactions returns the transactions associated with // the StakingOperation -func (o *StakingOperation) Transactions() []*Transaction { - return o.transactions +func (s *StakingOperation) Transactions() []*Transaction { + return s.transactions } // Sign will sign each transaction using the supplied key // This will halt and return an error if any of the transactions // fail to sign. -func (o *StakingOperation) Sign(k *ecdsa.PrivateKey) error { - for _, tx := range o.Transactions() { +func (s *StakingOperation) Sign(k *ecdsa.PrivateKey) error { + for _, tx := range s.Transactions() { if !tx.IsSigned() { err := tx.Sign(k) if err != nil { @@ -157,6 +179,84 @@ func (o *StakingOperation) Sign(k *ecdsa.PrivateKey) error { return nil } +func (s *StakingOperation) GetSignedVoluntaryExitMessages() ([]string, error) { + var signedVoluntaryExitMessages []string + + stakingOperationMetadata := s.model.GetMetadata().ArrayOfSignedVoluntaryExitMessageMetadata + + if s.model.Metadata == nil { + return []string{}, nil + } + + for _, metadata := range *stakingOperationMetadata { + decodedMessage, err := base64.StdEncoding.DecodeString(metadata.SignedVoluntaryExit) + if err != nil { + return nil, fmt.Errorf("failed to decode signed voluntary exit message: %s", err) + } + signedVoluntaryExitMessages = append(signedVoluntaryExitMessages, string(decodedMessage)) + } + + return signedVoluntaryExitMessages, nil +} + +func (c *Client) Wait(ctx context.Context, stakingOperation *StakingOperation, o ...WaitOption) (*StakingOperation, error) { + + options := &waitOptions{ + intervalSeconds: 5, + timeoutSeconds: 3600, + } + + for _, f := range o { + f(options) + } + + startTime := time.Now() + + for time.Since(startTime).Seconds() < float64(options.timeoutSeconds) { + so, err := c.fetchExternalStakingOperation(ctx, stakingOperation) + if err != nil { + return stakingOperation, err + } + stakingOperation = so + + if stakingOperation.isTerminalState() { + return stakingOperation, nil + } + + if time.Since(startTime).Seconds() > float64(options.timeoutSeconds) { + return stakingOperation, fmt.Errorf("staking operation timed out") + } + + time.Sleep(time.Duration(options.intervalSeconds) * time.Second) + } + + return stakingOperation, fmt.Errorf("staking operation timed out") +} + +// FetchExternalStakingOperation loads a staking operation from the API associated +// with an address. +func (c *Client) fetchExternalStakingOperation(ctx context.Context, stakingOperation *StakingOperation) (*StakingOperation, error) { + so, httpRes, err := c.client.StakeAPI.GetExternalStakingOperation( + ctx, + stakingOperation.NetworkID(), + stakingOperation.AddressID(), + stakingOperation.ID(), + ).Execute() + if err != nil { + return nil, err + } + + if httpRes.StatusCode != 200 { + return nil, fmt.Errorf("failed to fetch staking operation: %s", httpRes.Status) + } + + return newStakingOperationFromModel(so) +} + +func (s *StakingOperation) isTerminalState() bool { + return s.Status() == "complete" || s.Status() == "failed" +} + func newStakingOperationFromModel(m *client.StakingOperation) (*StakingOperation, error) { if m == nil { return nil, fmt.Errorf("staking operation model is nil") diff --git a/pkg/coinbase/staking_operation_test.go b/pkg/coinbase/staking_operation_test.go new file mode 100644 index 0000000..60037cb --- /dev/null +++ b/pkg/coinbase/staking_operation_test.go @@ -0,0 +1,219 @@ +package coinbase + +import ( + "context" + "fmt" + "net/http" + "testing" + + api "github.com/coinbase/coinbase-sdk-go/gen/client" + "github.com/coinbase/coinbase-sdk-go/pkg/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestStakingOperation_Wait_Success(t *testing.T) { + mc := &mockController{ + stakeAPI: mocks.NewStakeAPI(t), + } + mockGetExternalStakingOperation(t, mc.stakeAPI, http.StatusOK, "pending") + + c := &Client{ + client: &api.APIClient{ + StakeAPI: mc.stakeAPI, + AssetsAPI: mc.assetsAPI, + }, + } + + so, err := mockStakingOperation(t, "pending") + assert.NoError(t, err, "staking operation creation should not error") + so, err = c.Wait(context.Background(), so) + assert.NoError(t, err, "staking operation wait should not error") + assert.Equal(t, "complete", so.Status(), "staking operation status should be complete") + assert.Equal(t, len(so.Transactions()), 1, "staking operation should have 1 transaction") +} + +func TestStakingOperation_Wait_Success_CustomOptions(t *testing.T) { + mc := &mockController{ + stakeAPI: mocks.NewStakeAPI(t), + } + mockGetExternalStakingOperation(t, mc.stakeAPI, http.StatusOK, "pending") + + c := &Client{ + client: &api.APIClient{ + StakeAPI: mc.stakeAPI, + AssetsAPI: mc.assetsAPI, + }, + } + + so, err := mockStakingOperation(t, "pending") + assert.NoError(t, err, "staking operation creation should not error") + so, err = c.Wait( + context.Background(), + so, + WithWaitIntervalSeconds(1), + WithWaitTimeoutSeconds(3), + ) + assert.NoError(t, err, "staking operation wait should not error") + assert.Equal(t, "complete", so.Status(), "staking operation status should be complete") + assert.Equal(t, len(so.Transactions()), 1, "staking operation should have 1 transaction") +} + +func TestStakingOperation_Wait_Failure(t *testing.T) { + tests := map[string]struct { + soStatus string + setup func(*mockController) + expectedError string + }{ + "fail to fetch staking operation": { + setup: func(c *mockController) { + c.stakeAPI.On("GetExternalStakingOperation", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return( + api.ApiGetExternalStakingOperationRequest{ApiService: c.stakeAPI}, + ).Once() + c.stakeAPI.On("GetExternalStakingOperationExecute", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return( + nil, + nil, + fmt.Errorf("failed to fetch staking operation"), + ).Once() + }, + expectedError: "failed to fetch staking operation", + }, + "fail to fetch staking operation with failed http status": { + setup: func(c *mockController) { + c.stakeAPI.On("GetExternalStakingOperation", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return( + api.ApiGetExternalStakingOperationRequest{ApiService: c.stakeAPI}, + ).Once() + c.stakeAPI.On("GetExternalStakingOperationExecute", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return( + nil, + &http.Response{StatusCode: http.StatusInternalServerError}, + nil, + ).Once() + }, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + mc := &mockController{ + stakeAPI: mocks.NewStakeAPI(t), + } + tt.setup(mc) + + c := &Client{ + client: &api.APIClient{ + StakeAPI: mc.stakeAPI, + AssetsAPI: mc.assetsAPI, + }, + } + + so, err := mockStakingOperation(t, tt.soStatus) + assert.NoError(t, err, "staking operation creation should not error") + so, err = c.Wait(context.Background(), so) + assert.Error(t, err, "staking operation wait should error") + }) + } +} + +func TestStakingOperation_GetSignedVoluntaryExitMessages(t *testing.T) { + stakingOperation, err := mockStakingOperation(t, "pending") + assert.NoError(t, err, "staking operation creation should not error") + + SignedVoluntaryExitMessages, err := stakingOperation.GetSignedVoluntaryExitMessages() + assert.NoError(t, err, "get signed voluntary exit messages should not error") + + assert.Equal(t, 1, len(SignedVoluntaryExitMessages), "signed voluntary exit messages should have length 1") + assert.Equal(t, "test-data", SignedVoluntaryExitMessages[0], "signed voluntary exit messages should match") +} + +func mockStakingOperation(t *testing.T, status string) (*StakingOperation, error) { + t.Helper() + return newStakingOperationFromModel(&api.StakingOperation{ + Id: "staking-operation-id", + NetworkId: "test-network-id", + AddressId: "test-asset-id", + Status: status, + Transactions: []api.Transaction{ + { + NetworkId: "test-network-id", + Status: "pending", + UnsignedPayload: "7b2274797065223a22307832222c22636861696e4964223a2230783134613334222c226e6f6e63" + + "65223a22307830222c22746f223a22307834643965346633663464316138623566346637623166" + + "356235633762386436623262336231623062222c22676173223a22307835323038222c22676173" + + "5072696365223a6e756c6c2c226d61785072696f72697479466565506572476173223a223078" + + "3539363832663030222c226d6178466565506572476173223a2230783539363832663030222c22" + + "76616c7565223a2230783536626337356532643633313030303030222c22696e707574223a22" + + "3078222c226163636573734c697374223a5b5d2c2276223a22307830222c2272223a2230783022" + + "2c2273223a22307830222c2279506172697479223a22307830222c2268617368223a2230783664" + + "633334306534643663323633653363396561396135656438646561346332383966613861363966" + + "3031653635393462333732386230386138323335333433227d", + }, + }, + Metadata: &api.StakingOperationMetadata{ + ArrayOfSignedVoluntaryExitMessageMetadata: &[]api.SignedVoluntaryExitMessageMetadata{{ + SignedVoluntaryExit: "dGVzdC1kYXRh", + }}}, + }) +} + +func mockGetExternalStakingOperation(t *testing.T, stakeAPI *mocks.StakeAPI, statusCode int, soStatus string) { + t.Helper() + stakeAPI.On("GetExternalStakingOperation", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return( + api.ApiGetExternalStakingOperationRequest{ApiService: stakeAPI}, + ).Once() + stakeAPI.On("GetExternalStakingOperationExecute", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return( + &api.StakingOperation{ + Id: "staking-operation-id", + NetworkId: "test-network-id", + AddressId: "test-asset-id", + Status: soStatus, + Transactions: []api.Transaction{ + { + NetworkId: "test-network-id", + Status: "pending", + UnsignedPayload: "7b2274797065223a22307832222c22636861696e4964223a2230783134613334222c226e6f6e63" + + "65223a22307830222c22746f223a22307834643965346633663464316138623566346637623166" + + "356235633762386436623262336231623062222c22676173223a22307835323038222c22676173" + + "5072696365223a6e756c6c2c226d61785072696f72697479466565506572476173223a223078" + + "3539363832663030222c226d6178466565506572476173223a2230783539363832663030222c22" + + "76616c7565223a2230783536626337356532643633313030303030222c22696e707574223a22" + + "3078222c226163636573734c697374223a5b5d2c2276223a22307830222c2272223a2230783022" + + "2c2273223a22307830222c2279506172697479223a22307830222c2268617368223a2230783664" + + "633334306534643663323633653363396561396135656438646561346332383966613861363966" + + "3031653635393462333732386230386138323335333433227d", + }, + }, + }, + &http.Response{StatusCode: statusCode}, + nil, + ).Once() + stakeAPI.On("GetExternalStakingOperation", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return( + api.ApiGetExternalStakingOperationRequest{ApiService: stakeAPI}, + ).Once() + stakeAPI.On("GetExternalStakingOperationExecute", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return( + &api.StakingOperation{ + Id: "staking-operation-id", + NetworkId: "test-network-id", + AddressId: "test-asset-id", + Status: "complete", + Transactions: []api.Transaction{ + { + NetworkId: "test-network-id", + Status: "complete", + UnsignedPayload: "7b2274797065223a22307832222c22636861696e4964223a2230783134613334222c226e6f6e63" + + "65223a22307830222c22746f223a22307834643965346633663464316138623566346637623166" + + "356235633762386436623262336231623062222c22676173223a22307835323038222c22676173" + + "5072696365223a6e756c6c2c226d61785072696f72697479466565506572476173223a223078" + + "3539363832663030222c226d6178466565506572476173223a2230783539363832663030222c22" + + "76616c7565223a2230783536626337356532643633313030303030222c22696e707574223a22" + + "3078222c226163636573734c697374223a5b5d2c2276223a22307830222c2272223a2230783022" + + "2c2273223a22307830222c2279506172697479223a22307830222c2268617368223a2230783664" + + "633334306534643663323633653363396561396135656438646561346332383966613861363966" + + "3031653635393462333732386230386138323335333433227d", + }, + }, + }, + &http.Response{StatusCode: statusCode}, + nil, + ).Once() + +} diff --git a/pkg/coinbase/staking_reward_test.go b/pkg/coinbase/staking_reward_test.go index be00f74..a51829f 100644 --- a/pkg/coinbase/staking_reward_test.go +++ b/pkg/coinbase/staking_reward_test.go @@ -9,7 +9,7 @@ import ( "time" api "github.com/coinbase/coinbase-sdk-go/gen/client" - "github.com/coinbase/coinbase-sdk-go/pkg/coinbase/mocks" + "github.com/coinbase/coinbase-sdk-go/pkg/mocks" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) diff --git a/pkg/coinbase/utils.go b/pkg/coinbase/utils.go index cdc8d94..56b9165 100644 --- a/pkg/coinbase/utils.go +++ b/pkg/coinbase/utils.go @@ -7,6 +7,8 @@ const ( Wei = "wei" Gwei = "gwei" GweiDecimals = 9 + + StakingOperationModeNative = "native" ) func normalizeNetwork(network string) string { diff --git a/pkg/coinbase/validator.go b/pkg/coinbase/validator.go new file mode 100644 index 0000000..8ccffb9 --- /dev/null +++ b/pkg/coinbase/validator.go @@ -0,0 +1,66 @@ +package coinbase + +import ( + "context" + "fmt" + + "github.com/coinbase/coinbase-sdk-go/gen/client" +) + +type Validator struct { + validator client.Validator +} + +func NewValidator(validator client.Validator) Validator { + return Validator{ + validator: validator, + } +} + +func (v Validator) ID() string { + return v.validator.ValidatorId +} + +func (v Validator) Status() string { + return v.validator.Status +} + +func (v Validator) ToString() string { + return fmt.Sprintf( + "Validator { Id: '%s' Status: '%s' }", + v.ID(), + v.Status(), + ) +} + +func (c *Client) ListValidators(ctx context.Context, networkId string, assetId string) ([]Validator, error) { + validatorList, _, err := c.client.ValidatorsAPI.ListValidators( + ctx, + normalizeNetwork(networkId), + assetId, + ).Execute() + if err != nil { + return nil, err + } + + validators := make([]Validator, 0, len(validatorList.GetData())) + for i, validator := range validatorList.GetData() { + validators[i] = NewValidator(validator) + } + + return validators, nil +} + +func (c *Client) GetValidator(ctx context.Context, networkId string, assetId string, validatorId string) (Validator, error) { + validator, _, err := c.client.ValidatorsAPI.GetValidator( + ctx, + normalizeNetwork(networkId), + assetId, + validatorId, + ).Execute() + if err != nil { + return Validator{}, err + } + + return NewValidator(*validator), nil +} diff --git a/pkg/coinbase/mocks/AssetsAPI.go b/pkg/mocks/AssetsAPI.go similarity index 100% rename from pkg/coinbase/mocks/AssetsAPI.go rename to pkg/mocks/AssetsAPI.go diff --git a/pkg/coinbase/mocks/StakeAPI.go b/pkg/mocks/StakeAPI.go similarity index 100% rename from pkg/coinbase/mocks/StakeAPI.go rename to pkg/mocks/StakeAPI.go From 0224299753875ffa9b3f1290b65522458d693b9f Mon Sep 17 00:00:00 2001 From: Marcin Lenczewski Date: Thu, 22 Aug 2024 14:21:38 -0400 Subject: [PATCH 2/3] Lint fix --- pkg/coinbase/staking_operation_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/coinbase/staking_operation_test.go b/pkg/coinbase/staking_operation_test.go index 60037cb..413da00 100644 --- a/pkg/coinbase/staking_operation_test.go +++ b/pkg/coinbase/staking_operation_test.go @@ -108,7 +108,7 @@ func TestStakingOperation_Wait_Failure(t *testing.T) { so, err := mockStakingOperation(t, tt.soStatus) assert.NoError(t, err, "staking operation creation should not error") - so, err = c.Wait(context.Background(), so) + _, err = c.Wait(context.Background(), so) assert.Error(t, err, "staking operation wait should error") }) } From bcce09a58ef8f5082d3aac998e69706b614dc0e2 Mon Sep 17 00:00:00 2001 From: marcin-cb <114105519+marcin-cb@users.noreply.github.com> Date: Thu, 22 Aug 2024 15:49:25 -0400 Subject: [PATCH 3/3] Update pkg/coinbase/staking_operation.go Co-authored-by: Rohit Durvasula <88731568+drohit-cb@users.noreply.github.com> --- pkg/coinbase/staking_operation.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/coinbase/staking_operation.go b/pkg/coinbase/staking_operation.go index 0524337..c6f1aa9 100644 --- a/pkg/coinbase/staking_operation.go +++ b/pkg/coinbase/staking_operation.go @@ -22,7 +22,7 @@ func WithStakingOperationMode(mode string) StakingOperationOption { } // WithStakingOperationImmediate allows for the setting of the immediate flag -// specifically for Dedicate ETH Staking whether to immediate unstake or not. (i.e. `true` or `false`) +// specifically for Dedicated ETH Staking whether to immediate unstake or not. (i.e. `true` or `false`) func WithStakingOperationImmediate(immediate string) StakingOperationOption { return WithStakingOperationOption("immediate", immediate) }