diff --git a/CHANGELOG.md b/CHANGELOG.md index c89208c..7b5b7f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,11 @@ ## Unreleased +## [0.0.11] - 2024-09-23 + - Fix a bug where correlation id returned from backend wasn't being relayed to the user +- Add FetchStakingOperation to fetch a staking operation by networkID, addressID and stakingOperationID +- Add ReloadStakingOperation to reload a given staking operation with latest data from the backend ## [0.0.10] - 2024-09-20 diff --git a/examples/ethereum/dedicated-eth-stake/main.go b/examples/ethereum/dedicated-eth-stake/main.go index fbaed0d..9afffe8 100644 --- a/examples/ethereum/dedicated-eth-stake/main.go +++ b/examples/ethereum/dedicated-eth-stake/main.go @@ -52,8 +52,7 @@ func main() { log.Fatalf("error building staking operation: %v", err) } - stakeOperation, err = client.Wait(ctx, stakeOperation, coinbase.WithWaitTimeoutSeconds(600)) - if err != nil { + if err := client.Wait(ctx, stakeOperation, coinbase.WithWaitTimeoutSeconds(600)); err != nil { log.Fatalf("error waiting for staking operation: %v", err) } diff --git a/examples/solana/claim-stake/main.go b/examples/solana/claim-stake/main.go index 6227b3b..2937453 100644 --- a/examples/solana/claim-stake/main.go +++ b/examples/solana/claim-stake/main.go @@ -54,7 +54,7 @@ func main() { log.Printf("Staking operation ID: %s\n\n", stakingOperation.ID()) - stakingOperation, err = client.Wait(ctx, stakingOperation, coinbase.WithWaitTimeoutSeconds(60)) + err = client.Wait(ctx, stakingOperation, coinbase.WithWaitTimeoutSeconds(60)) if err != nil { log.Fatalf("error waiting for staking operation: %v", err) } diff --git a/examples/solana/stake/main.go b/examples/solana/stake/main.go index e93f7be..f2f259b 100644 --- a/examples/solana/stake/main.go +++ b/examples/solana/stake/main.go @@ -54,7 +54,7 @@ func main() { log.Printf("Staking operation ID: %s\n\n", stakingOperation.ID()) - stakingOperation, err = client.Wait(ctx, stakingOperation, coinbase.WithWaitTimeoutSeconds(60)) + err = client.Wait(ctx, stakingOperation, coinbase.WithWaitTimeoutSeconds(60)) if err != nil { log.Fatalf("error waiting for staking operation: %v", err) } diff --git a/examples/solana/unstake/main.go b/examples/solana/unstake/main.go index 6c39852..b125bca 100644 --- a/examples/solana/unstake/main.go +++ b/examples/solana/unstake/main.go @@ -54,7 +54,7 @@ func main() { log.Printf("Staking operation ID: %s\n\n", stakingOperation.ID()) - stakingOperation, err = client.Wait(ctx, stakingOperation, coinbase.WithWaitTimeoutSeconds(60)) + err = client.Wait(ctx, stakingOperation, coinbase.WithWaitTimeoutSeconds(60)) if err != nil { log.Fatalf("error waiting for staking operation: %v", err) } diff --git a/pkg/auth/transport.go b/pkg/auth/transport.go index 92d6aae..34c7519 100644 --- a/pkg/auth/transport.go +++ b/pkg/auth/transport.go @@ -36,7 +36,7 @@ func (t *transport) RoundTrip(req *http.Request) (*http.Response, error) { "Correlation-Context", fmt.Sprintf( "%s,%s", - fmt.Sprintf("%s=%s", "sdk_version", "0.0.10"), + fmt.Sprintf("%s=%s", "sdk_version", "0.0.11"), fmt.Sprintf("%s=%s", "sdk_language", "go"), ), ) diff --git a/pkg/coinbase/staking_operation.go b/pkg/coinbase/staking_operation.go index 3b93bc8..5af4269 100644 --- a/pkg/coinbase/staking_operation.go +++ b/pkg/coinbase/staking_operation.go @@ -193,7 +193,7 @@ func (s *StakingOperation) GetSignedVoluntaryExitMessages() ([]string, error) { return signedVoluntaryExitMessages, nil } -func (c *Client) Wait(ctx context.Context, stakingOperation *StakingOperation, o ...WaitOption) (*StakingOperation, error) { +func (c *Client) Wait(ctx context.Context, stakingOperation *StakingOperation, o ...WaitOption) error { options := &waitOptions{ intervalSeconds: 5, timeoutSeconds: 3600, @@ -206,29 +206,27 @@ func (c *Client) Wait(ctx context.Context, stakingOperation *StakingOperation, o startTime := time.Now() for time.Since(startTime).Seconds() < float64(options.timeoutSeconds) { - so, err := c.fetchExternalStakingOperation(ctx, stakingOperation) - if err != nil { - return stakingOperation, err + if err := c.ReloadStakingOperation(ctx, stakingOperation); err != nil { + return err } - stakingOperation = so if stakingOperation.isTerminalState() { - return stakingOperation, nil + return nil } if time.Since(startTime).Seconds() > float64(options.timeoutSeconds) { - return stakingOperation, fmt.Errorf("staking operation timed out") + return fmt.Errorf("staking operation timed out") } time.Sleep(time.Duration(options.intervalSeconds) * time.Second) } - return stakingOperation, fmt.Errorf("staking operation timed out") + return fmt.Errorf("staking operation timed out") } -// FetchExternalStakingOperation reloads a staking operation from the API associated -// with an address. -func (c *Client) fetchExternalStakingOperation(ctx context.Context, stakingOperation *StakingOperation) (*StakingOperation, error) { +// ReloadStakingOperation reloads a staking operation from the backend with the latest state. +// It ensures only newly constructed transactions are added to the staking operation and any existing transactions are untouched. +func (c *Client) ReloadStakingOperation(ctx context.Context, stakingOperation *StakingOperation) error { so, httpResp, err := c.client.StakeAPI.GetExternalStakingOperation( ctx, stakingOperation.NetworkID(), @@ -236,20 +234,32 @@ func (c *Client) fetchExternalStakingOperation(ctx context.Context, stakingOpera stakingOperation.ID(), ).Execute() if err != nil { - return nil, errors.MapToUserFacing(err, httpResp) + return errors.MapToUserFacing(err, httpResp) } stakingOperation.model = so + for _, tx := range so.Transactions { if !stakingOperation.hasTransactionByUnsignedPayload(tx.UnsignedPayload) { newTx, err := newTransactionFromModel(&tx) if err != nil { - return nil, err + return err } + stakingOperation.transactions = append(stakingOperation.transactions, newTx) } } - return stakingOperation, nil + return nil +} + +// FetchStakingOperation fetches a staking operation from the backend given a networkID, addressID, and stakingOperationID. +func (c *Client) FetchStakingOperation(ctx context.Context, networkID, addressID, stakingOperationID string) (*StakingOperation, error) { + so, httpResp, err := c.client.StakeAPI.GetExternalStakingOperation(ctx, networkID, addressID, stakingOperationID).Execute() + if err != nil { + return nil, errors.MapToUserFacing(err, httpResp) + } + + return newStakingOperationFromModel(so) } func (s *StakingOperation) hasTransactionByUnsignedPayload(unsignedPayload string) bool { diff --git a/pkg/coinbase/staking_operation_test.go b/pkg/coinbase/staking_operation_test.go index 00b6082..28f5fb1 100644 --- a/pkg/coinbase/staking_operation_test.go +++ b/pkg/coinbase/staking_operation_test.go @@ -34,8 +34,7 @@ func (s *StakingOperationSuite) TestStakingOperation_Wait_Success() { c := &Client{ client: &api.APIClient{ - StakeAPI: mc.stakeAPI, - AssetsAPI: mc.assetsAPI, + StakeAPI: mc.stakeAPI, }, } @@ -47,7 +46,7 @@ func (s *StakingOperationSuite) TestStakingOperation_Wait_Success() { s.NoError(err, "failed to sign staking operation") signedPayload := so.Transactions()[0].SignedPayload() s.NotEmpty(signedPayload, "signed payload should not be empty") - so, err = c.Wait(context.Background(), so) + err = c.Wait(context.Background(), so) s.NoError(err, "staking operation wait should not error") s.Equal("complete", so.Status(), "staking operation status should be complete") s.Equal(1, len(so.Transactions()), "staking operation should have 1 transaction") @@ -62,14 +61,13 @@ func (s *StakingOperationSuite) TestStakingOperation_Wait_Success_CustomOptions( c := &Client{ client: &api.APIClient{ - StakeAPI: mc.stakeAPI, - AssetsAPI: mc.assetsAPI, + StakeAPI: mc.stakeAPI, }, } so, err := mockStakingOperation(s.T(), "pending") s.NoError(err, "staking operation creation should not error") - so, err = c.Wait( + err = c.Wait( context.Background(), so, WithWaitIntervalSeconds(1), @@ -110,14 +108,13 @@ func (s *StakingOperationSuite) TestStakingOperation_Wait_Failure() { c := &Client{ client: &api.APIClient{ - StakeAPI: mc.stakeAPI, - AssetsAPI: mc.assetsAPI, + StakeAPI: mc.stakeAPI, }, } so, err := mockStakingOperation(t, tt.soStatus) s.NoError(err, "staking operation creation should not error") - _, err = c.Wait(context.Background(), so) + err = c.Wait(context.Background(), so) s.Error(err, "staking operation wait should error") }) } @@ -167,6 +164,7 @@ func mockStakingOperation(t *testing.T, status string) (*StakingOperation, error 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() @@ -227,6 +225,18 @@ func mockGetExternalStakingOperation(t *testing.T, stakeAPI *mocks.StakeAPI, sta ).Once() } +func mockGetExternalStakingOperationWithData(t *testing.T, stakingAPI *mocks.StakeAPI, statusCode int, op *api.StakingOperation) { + t.Helper() + + stakingAPI.On("GetExternalStakingOperation", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return( + api.ApiGetExternalStakingOperationRequest{ApiService: stakingAPI}, + ).Once() + + stakingAPI.On("GetExternalStakingOperationExecute", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return( + op, &http.Response{StatusCode: statusCode}, nil, + ).Once() +} + func (s *StakingOperationSuite) TestSign_AllTransactionsSigned() { // Create mock transactions signable1 := new(mocks.Signable) @@ -333,3 +343,178 @@ func (s *StakingOperationSuite) TestSign_SignTransactionFails() { signable1.AssertNotCalled(s.T(), "Sign", mock.Anything) signable2.AssertCalled(s.T(), "Sign", signer) } + +func (s *StakingOperationSuite) TestReloadStakingOperation_ExistingTransactionsNotOverwritten() { + var ( + networkID = "ethereum-holesky" + addressID = "0x14a34" + stakingOperationID = "staking-operation-id" + stakingOperationStatus = "pending" + existingUnsignedPayload = dummyEthereumUnsignedPayload(s.T(), 0) + newUnsignedPayload = dummyEthereumUnsignedPayload(s.T(), 1) + stakingAPIMock = mocks.NewStakeAPI(s.T()) + + newStakingOperation = &api.StakingOperation{ + Id: stakingOperationID, + NetworkId: networkID, + AddressId: addressID, + Status: stakingOperationStatus, + Transactions: []api.Transaction{ + { + NetworkId: networkID, + Status: "pending", + UnsignedPayload: existingUnsignedPayload, + }, + { + NetworkId: networkID, + Status: "pending", + UnsignedPayload: newUnsignedPayload, + }, + }, + } + ) + + mockGetExternalStakingOperationWithData(s.T(), stakingAPIMock, http.StatusOK, newStakingOperation) + + c := &Client{ + client: &api.APIClient{ + StakeAPI: stakingAPIMock, + }, + } + + // Create a staking operation with an existing transaction + stakingOp := &StakingOperation{ + model: &client.StakingOperation{ + Id: stakingOperationID, + NetworkId: networkID, + AddressId: addressID, + Status: stakingOperationStatus, + }, + transactions: []*Transaction{ + { + model: &client.Transaction{ + UnsignedPayload: existingUnsignedPayload, + }, + }, + }, + } + + err := c.ReloadStakingOperation(context.Background(), stakingOp) + s.NoError(err, "reload staking operation should not error") + + // Ensure the existing transaction is not overwritten + s.Equal(2, len(stakingOp.transactions), "staking operation should have 1 transaction") + s.Equal(existingUnsignedPayload, stakingOp.transactions[0].UnsignedPayload(), "existing transaction should not be overwritten") + s.Equal(newUnsignedPayload, stakingOp.transactions[1].UnsignedPayload(), "new transaction should be added") +} + +func (s *StakingOperationSuite) TestReloadStakingOperation_ErrorFormatting() { + var ( + networkID = "ethereum-holesky" + addressID = "0x14a34" + stakingOperationID = "staking-operation-id" + stakingOperationStatus = "pending" + stakingAPIMock = mocks.NewStakeAPI(s.T()) + ) + + stakingAPIMock.On("GetExternalStakingOperation", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return( + api.ApiGetExternalStakingOperationRequest{ApiService: stakingAPIMock}, + ).Once() + + stakingAPIMock.On("GetExternalStakingOperationExecute", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return( + nil, nil, fmt.Errorf("backend error"), + ).Once() + + c := &Client{ + client: &api.APIClient{ + StakeAPI: stakingAPIMock, + }, + } + + // Create a staking operation with an existing transaction + stakingOp := &StakingOperation{ + model: &client.StakingOperation{ + Id: stakingOperationID, + NetworkId: networkID, + AddressId: addressID, + Status: stakingOperationStatus, + }, + } + + // Call ReloadStakingOperation + err := c.ReloadStakingOperation(context.Background(), stakingOp) + s.Error(err, "reload staking operation should error") + s.Contains(err.Error(), "backend error", "error message should be user-facing") +} + +func (s *StakingOperationSuite) TestFetchStakingOperation_Success() { + var ( + networkID = "ethereum-holesky" + addressID = "0x14a34" + stakingOperationID = "staking-operation-id" + stakingOperationStatus = "pending" + unsignedPayload = dummyEthereumUnsignedPayload(s.T(), 0) + stakingAPIMock = mocks.NewStakeAPI(s.T()) + + fetchedStakingOperation = &api.StakingOperation{ + Id: stakingOperationID, + NetworkId: networkID, + AddressId: addressID, + Status: stakingOperationStatus, + Transactions: []api.Transaction{ + { + NetworkId: networkID, + Status: "pending", + UnsignedPayload: unsignedPayload, + }, + }, + } + ) + + mockGetExternalStakingOperationWithData(s.T(), stakingAPIMock, http.StatusOK, fetchedStakingOperation) + + c := &Client{ + client: &api.APIClient{ + StakeAPI: stakingAPIMock, + }, + } + + stakingOp, err := c.FetchStakingOperation(context.Background(), networkID, addressID, stakingOperationID) + s.NoError(err, "fetch staking operation should not error") + + // Ensure the fetched staking operation is correct + s.Equal(stakingOperationID, stakingOp.ID(), "staking operation ID should match") + s.Equal(networkID, stakingOp.NetworkID(), "network ID should match") + s.Equal(addressID, stakingOp.AddressID(), "address ID should match") + s.Equal(stakingOperationStatus, stakingOp.Status(), "status should match") + s.Equal(1, len(stakingOp.Transactions()), "staking operation should have 1 transaction") + s.Equal(unsignedPayload, stakingOp.Transactions()[0].UnsignedPayload(), "transaction unsigned payload should match") +} + +func (s *StakingOperationSuite) TestFetchStakingOperation_Error() { + var ( + networkID = "ethereum-holesky" + addressID = "0x14a34" + stakingOperationID = "staking-operation-id" + stakingAPIMock = mocks.NewStakeAPI(s.T()) + ) + + stakingAPIMock.On("GetExternalStakingOperation", mock.Anything, networkID, addressID, stakingOperationID).Return( + api.ApiGetExternalStakingOperationRequest{ApiService: stakingAPIMock}, + ).Once() + + stakingAPIMock.On("GetExternalStakingOperationExecute", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return( + nil, nil, fmt.Errorf("backend error"), + ).Once() + + c := &Client{ + client: &api.APIClient{ + StakeAPI: stakingAPIMock, + }, + } + + stakingOp, err := c.FetchStakingOperation(context.Background(), networkID, addressID, stakingOperationID) + s.Error(err, "fetch staking operation should error") + s.Nil(stakingOp, "staking operation should be nil on error") + s.Contains(err.Error(), "backend error", "error message should be user-facing") +} diff --git a/pkg/coinbase/test_utils.go b/pkg/coinbase/test_utils.go new file mode 100644 index 0000000..67f5140 --- /dev/null +++ b/pkg/coinbase/test_utils.go @@ -0,0 +1,35 @@ +package coinbase + +import ( + "encoding/hex" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/require" +) + +// dummyEthereumUnsignedPayload creates a new Ethereum dummy transaction with minimal inputs. +// This should be deterministic and not rely on any external state. +func dummyEthereumUnsignedPayload(t *testing.T, nonce uint64) string { + t.Helper() + + // Create a simple Ethereum transaction + tx := types.NewTx(&types.LegacyTx{ + Nonce: nonce, + To: &common.Address{}, + Value: big.NewInt(0), + Gas: 21000, + GasPrice: big.NewInt(1), + Data: nil, + }) + + // Encode the transaction to hex + txBytes, err := tx.MarshalJSON() + if err != nil { + require.NoError(t, err) + } + + return hex.EncodeToString(txBytes) +} diff --git a/pkg/coinbase/test_utils_test.go b/pkg/coinbase/test_utils_test.go new file mode 100644 index 0000000..c560920 --- /dev/null +++ b/pkg/coinbase/test_utils_test.go @@ -0,0 +1,33 @@ +package coinbase + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDummyEthereumUnsignedPayload(t *testing.T) { + tests := []struct { + name string + nonce uint64 + want string + }{ + { + name: "nonce 0", + nonce: 0, + want: "7b2274797065223a22307830222c226e6f6e6365223a22307830222c22746f223a22307830303030303030303030303030303030303030303030303030303030303030303030303030303030222c22676173223a22307835323038222c226761735072696365223a22307831222c226d61785072696f72697479466565506572476173223a6e756c6c2c226d6178466565506572476173223a6e756c6c2c2276616c7565223a22307830222c22696e707574223a223078222c2276223a22307830222c2272223a22307830222c2273223a22307830222c2268617368223a22307837623864613336316233363132613265336534313666666266373032393634323534643763393232663565323762623165366561396165623233303336333665227d", + }, + { + name: "nonce 1", + nonce: 1, + want: "7b2274797065223a22307830222c226e6f6e6365223a22307831222c22746f223a22307830303030303030303030303030303030303030303030303030303030303030303030303030303030222c22676173223a22307835323038222c226761735072696365223a22307831222c226d61785072696f72697479466565506572476173223a6e756c6c2c226d6178466565506572476173223a6e756c6c2c2276616c7565223a22307830222c22696e707574223a223078222c2276223a22307830222c2272223a22307830222c2273223a22307830222c2268617368223a22307864303962383361316338393636353738306665386164613931643962616435313864313035313133323364623331333631393033306661656533363964346266227d", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := dummyEthereumUnsignedPayload(t, tt.nonce) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/pkg/coinbase/transaction.go b/pkg/coinbase/transaction.go index 93b22a9..9fbb346 100644 --- a/pkg/coinbase/transaction.go +++ b/pkg/coinbase/transaction.go @@ -112,8 +112,7 @@ func newTransactionFromModel(m *client.Transaction) (*Transaction, error) { } t := &types.Transaction{} - err = t.UnmarshalJSON(rawHex) - if err != nil { + if err := t.UnmarshalJSON(rawHex); err != nil { return nil, err }