diff --git a/clients/stellarcore/client_test.go b/clients/stellarcore/client_test.go index 90f4c1cc55..6cfd01b210 100644 --- a/clients/stellarcore/client_test.go +++ b/clients/stellarcore/client_test.go @@ -5,9 +5,10 @@ import ( "net/http" "testing" + "github.com/stretchr/testify/assert" + proto "github.com/stellar/go/protocols/stellarcore" "github.com/stellar/go/support/http/httptest" - "github.com/stretchr/testify/assert" ) func TestSubmitTransaction(t *testing.T) { @@ -27,6 +28,26 @@ func TestSubmitTransaction(t *testing.T) { } } +func TestSubmitTransactionError(t *testing.T) { + hmock := httptest.NewClient() + c := &Client{HTTP: hmock, URL: "http://localhost:11626"} + + // happy path - new transaction + hmock.On("GET", "http://localhost:11626/tx?blob=foo"). + ReturnString( + 200, + `{"diagnostic_events":"AAAAAQAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAgAAAA8AAAAFZXJyb3IAAAAAAAACAAAAAwAAAAUAAAAQAAAAAQAAAAMAAAAOAAAAU3RyYW5zYWN0aW9uIGBzb3JvYmFuRGF0YS5yZXNvdXJjZUZlZWAgaXMgbG93ZXIgdGhhbiB0aGUgYWN0dWFsIFNvcm9iYW4gcmVzb3VyY2UgZmVlAAAAAAUAAAAAAAEJcwAAAAUAAAAAAAG6fA==","error":"AAAAAAABCdf////vAAAAAA==","status":"ERROR"}`, + ) + + resp, err := c.SubmitTransaction(context.Background(), "foo") + + if assert.NoError(t, err) { + assert.Equal(t, "ERROR", resp.Status) + assert.Equal(t, resp.Error, "AAAAAAABCdf////vAAAAAA==") + assert.Equal(t, "AAAAAQAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAgAAAA8AAAAFZXJyb3IAAAAAAAACAAAAAwAAAAUAAAAQAAAAAQAAAAMAAAAOAAAAU3RyYW5zYWN0aW9uIGBzb3JvYmFuRGF0YS5yZXNvdXJjZUZlZWAgaXMgbG93ZXIgdGhhbiB0aGUgYWN0dWFsIFNvcm9iYW4gcmVzb3VyY2UgZmVlAAAAAAUAAAAAAAEJcwAAAAUAAAAAAAG6fA==", resp.DiagnosticEvents) + } +} + func TestManualClose(t *testing.T) { hmock := httptest.NewClient() c := &Client{HTTP: hmock, URL: "http://localhost:11626"} diff --git a/protocols/stellarcore/tx_response.go b/protocols/stellarcore/tx_response.go index ee8556adc3..c4434ea280 100644 --- a/protocols/stellarcore/tx_response.go +++ b/protocols/stellarcore/tx_response.go @@ -1,5 +1,9 @@ package stellarcore +import ( + "github.com/stellar/go/xdr" +) + const ( // TXStatusError represents the status value returned by stellar-core when an error occurred from // submitting a transaction @@ -25,9 +29,42 @@ type TXResponse struct { Exception string `json:"exception"` Error string `json:"error"` Status string `json:"status"` + // DiagnosticEvents is an optional base64-encoded XDR Variable-Length Array of DiagnosticEvents + DiagnosticEvents string `json:"diagnostic_events,omitempty"` } // IsException returns true if the response represents an exception response from stellar-core func (resp *TXResponse) IsException() bool { return resp.Exception != "" } + +// DecodeDiagnosticEvents returns the decoded events +func DecodeDiagnosticEvents(events string) ([]xdr.DiagnosticEvent, error) { + var ret []xdr.DiagnosticEvent + if events == "" { + return ret, nil + } + err := xdr.SafeUnmarshalBase64(events, &ret) + if err != nil { + return nil, err + } + return ret, err +} + +// DiagnosticEventsToSlice transforms the base64 diagnostic events into a slice of individual +// base64-encoded diagnostic events +func DiagnosticEventsToSlice(events string) ([]string, error) { + decoded, err := DecodeDiagnosticEvents(events) + if err != nil { + return nil, err + } + result := make([]string, len(decoded)) + for i := 0; i < len(decoded); i++ { + encoded, err := xdr.MarshalBase64(decoded[i]) + if err != nil { + return nil, err + } + result[i] = encoded + } + return result, nil +} diff --git a/protocols/stellarcore/tx_response_test.go b/protocols/stellarcore/tx_response_test.go new file mode 100644 index 0000000000..bf2baf90d0 --- /dev/null +++ b/protocols/stellarcore/tx_response_test.go @@ -0,0 +1,16 @@ +package stellarcore + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDiagnosticEventsToSlice(t *testing.T) { + events := "AAAAAgAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAgAAAA8AAAAFZXJyb3IAAAAAAAACAAAAAwAAAAUAAAAQAAAAAQAAAAMAAAAOAAAAU3RyYW5zYWN0aW9uIGBzb3JvYmFuRGF0YS5yZXNvdXJjZUZlZWAgaXMgbG93ZXIgdGhhbiB0aGUgYWN0dWFsIFNvcm9iYW4gcmVzb3VyY2UgZmVlAAAAAAUAAAAAAAEJcwAAAAUAAAAAAAG6fAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAgAAAA8AAAAFZXJyb3IAAAAAAAACAAAAAwAAAAUAAAAQAAAAAQAAAAMAAAAOAAAAU3RyYW5zYWN0aW9uIGBzb3JvYmFuRGF0YS5yZXNvdXJjZUZlZWAgaXMgbG93ZXIgdGhhbiB0aGUgYWN0dWFsIFNvcm9iYW4gcmVzb3VyY2UgZmVlAAAAAAUAAAAAAAEJcwAAAAUAAAAAAAG6fA==" + slice, err := DiagnosticEventsToSlice(events) + require.NoError(t, err) + require.Len(t, slice, 2) + require.Equal(t, slice[0], "AAAAAAAAAAAAAAAAAAAAAgAAAAAAAAACAAAADwAAAAVlcnJvcgAAAAAAAAIAAAADAAAABQAAABAAAAABAAAAAwAAAA4AAABTdHJhbnNhY3Rpb24gYHNvcm9iYW5EYXRhLnJlc291cmNlRmVlYCBpcyBsb3dlciB0aGFuIHRoZSBhY3R1YWwgU29yb2JhbiByZXNvdXJjZSBmZWUAAAAABQAAAAAAAQlzAAAABQAAAAAAAbp8") + require.Equal(t, slice[1], "AAAAAAAAAAAAAAAAAAAAAgAAAAAAAAACAAAADwAAAAVlcnJvcgAAAAAAAAIAAAADAAAABQAAABAAAAABAAAAAwAAAA4AAABTdHJhbnNhY3Rpb24gYHNvcm9iYW5EYXRhLnJlc291cmNlRmVlYCBpcyBsb3dlciB0aGFuIHRoZSBhY3R1YWwgU29yb2JhbiByZXNvdXJjZSBmZWUAAAAABQAAAAAAAQlzAAAABQAAAAAAAbp8") +} diff --git a/services/horizon/internal/actions/submit_transaction.go b/services/horizon/internal/actions/submit_transaction.go index 4c8f2fd691..b877f75a7b 100644 --- a/services/horizon/internal/actions/submit_transaction.go +++ b/services/horizon/internal/actions/submit_transaction.go @@ -8,6 +8,7 @@ import ( "github.com/stellar/go/network" "github.com/stellar/go/protocols/horizon" + "github.com/stellar/go/protocols/stellarcore" hProblem "github.com/stellar/go/services/horizon/internal/render/problem" "github.com/stellar/go/services/horizon/internal/resourceadapter" "github.com/stellar/go/services/horizon/internal/txsub" @@ -95,15 +96,30 @@ func (handler SubmitTransactionHandler) response(r *http.Request, info envelopeI return nil, &hProblem.ClientDisconnected } - switch err := result.Err.(type) { - case *txsub.FailedTransactionError: + if failedErr, ok := result.Err.(*txsub.FailedTransactionError); ok { rcr := horizon.TransactionResultCodes{} - resourceadapter.PopulateTransactionResultCodes( + err := resourceadapter.PopulateTransactionResultCodes( r.Context(), info.hash, &rcr, - err, + failedErr, ) + if err != nil { + return nil, failedErr + } + + extras := map[string]interface{}{ + "envelope_xdr": info.raw, + "result_xdr": failedErr.ResultXDR, + "result_codes": rcr, + } + if failedErr.DiagnosticEventsXDR != "" { + events, err := stellarcore.DiagnosticEventsToSlice(failedErr.DiagnosticEventsXDR) + if err != nil { + return nil, err + } + extras["diagnostic_events"] = events + } return nil, &problem.P{ Type: "transaction_failed", @@ -113,11 +129,7 @@ func (handler SubmitTransactionHandler) response(r *http.Request, info envelopeI "The `extras.result_codes` field on this response contains further " + "details. Descriptions of each code can be found at: " + "https://developers.stellar.org/api/errors/http-status-codes/horizon-specific/transaction-failed/", - Extras: map[string]interface{}{ - "envelope_xdr": info.raw, - "result_xdr": err.ResultXDR, - "result_codes": rcr, - }, + Extras: extras, } } diff --git a/services/horizon/internal/actions/submit_transaction_test.go b/services/horizon/internal/actions/submit_transaction_test.go index 273099b528..a15ce3bd94 100644 --- a/services/horizon/internal/actions/submit_transaction_test.go +++ b/services/horizon/internal/actions/submit_transaction_test.go @@ -9,15 +9,16 @@ import ( "testing" "time" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "github.com/stellar/go/network" "github.com/stellar/go/services/horizon/internal/corestate" hProblem "github.com/stellar/go/services/horizon/internal/render/problem" "github.com/stellar/go/services/horizon/internal/txsub" "github.com/stellar/go/support/render/problem" "github.com/stellar/go/xdr" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" ) func TestStellarCoreMalformedTx(t *testing.T) { @@ -199,3 +200,47 @@ func TestDisableTxSubFlagSubmission(t *testing.T) { _, err = handler.GetResource(w, request) assert.Equal(t, p, err) } + +func TestSubmissionSorobanDiagnosticEvents(t *testing.T) { + mockSubmitChannel := make(chan txsub.Result, 1) + mock := &coreStateGetterMock{} + mock.On("GetCoreState").Return(corestate.State{ + Synced: true, + }) + + mockSubmitter := &networkSubmitterMock{} + mockSubmitter.On("Submit").Return(mockSubmitChannel) + mockSubmitChannel <- txsub.Result{ + Err: &txsub.FailedTransactionError{ + ResultXDR: "AAAAAAABCdf////vAAAAAA==", + DiagnosticEventsXDR: "AAAAAQAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAgAAAA8AAAAFZXJyb3IAAAAAAAACAAAAAwAAAAUAAAAQAAAAAQAAAAMAAAAOAAAAU3RyYW5zYWN0aW9uIGBzb3JvYmFuRGF0YS5yZXNvdXJjZUZlZWAgaXMgbG93ZXIgdGhhbiB0aGUgYWN0dWFsIFNvcm9iYW4gcmVzb3VyY2UgZmVlAAAAAAUAAAAAAAEJcwAAAAUAAAAAAAG6fA==", + }, + } + + handler := SubmitTransactionHandler{ + Submitter: mockSubmitter, + NetworkPassphrase: network.PublicNetworkPassphrase, + CoreStateGetter: mock, + } + + form := url.Values{} + form.Set("tx", "AAAAAAGUcmKO5465JxTSLQOQljwk2SfqAJmZSG6JH6wtqpwhAAABLAAAAAAAAAABAAAAAAAAAAEAAAALaGVsbG8gd29ybGQAAAAAAwAAAAAAAAAAAAAAABbxCy3mLg3hiTqX4VUEEp60pFOrJNxYM1JtxXTwXhY2AAAAAAvrwgAAAAAAAAAAAQAAAAAW8Qst5i4N4Yk6l+FVBBKetKRTqyTcWDNSbcV08F4WNgAAAAAN4Lazj4x61AAAAAAAAAAFAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABLaqcIQAAAEBKwqWy3TaOxoGnfm9eUjfTRBvPf34dvDA0Nf+B8z4zBob90UXtuCqmQqwMCyH+okOI3c05br3khkH0yP4kCwcE") + + request, err := http.NewRequest( + "POST", + "https://horizon.stellar.org/transactions", + strings.NewReader(form.Encode()), + ) + + require.NoError(t, err) + request.Header.Add("Content-Type", "application/x-www-form-urlencoded") + + w := httptest.NewRecorder() + _, err = handler.GetResource(w, request) + require.Error(t, err) + require.IsType(t, &problem.P{}, err) + require.Contains(t, err.(*problem.P).Extras, "diagnostic_events") + require.IsType(t, []string{}, err.(*problem.P).Extras["diagnostic_events"]) + diagnosticEvents := err.(*problem.P).Extras["diagnostic_events"].([]string) + require.Equal(t, diagnosticEvents, []string{"AAAAAAAAAAAAAAAAAAAAAgAAAAAAAAACAAAADwAAAAVlcnJvcgAAAAAAAAIAAAADAAAABQAAABAAAAABAAAAAwAAAA4AAABTdHJhbnNhY3Rpb24gYHNvcm9iYW5EYXRhLnJlc291cmNlRmVlYCBpcyBsb3dlciB0aGFuIHRoZSBhY3R1YWwgU29yb2JhbiByZXNvdXJjZSBmZWUAAAAABQAAAAAAAQlzAAAABQAAAAAAAbp8"}) +} diff --git a/services/horizon/internal/codes/main.go b/services/horizon/internal/codes/main.go index 5af63bed1a..ebf90a0233 100644 --- a/services/horizon/internal/codes/main.go +++ b/services/horizon/internal/codes/main.go @@ -79,6 +79,8 @@ func String(code interface{}) (string, error) { return "tx_bad_minseq_age_or_gap", nil case xdr.TransactionResultCodeTxMalformed: return "tx_malformed", nil + case xdr.TransactionResultCodeTxSorobanInvalid: + return "tx_soroban_invalid", nil } case xdr.OperationResultCode: switch code { diff --git a/services/horizon/internal/txsub/errors.go b/services/horizon/internal/txsub/errors.go index 5652498327..160ea7a4a4 100644 --- a/services/horizon/internal/txsub/errors.go +++ b/services/horizon/internal/txsub/errors.go @@ -16,7 +16,7 @@ var ( // ErrBadSequence is a canned error response for transactions whose sequence // number is wrong. - ErrBadSequence = &FailedTransactionError{"AAAAAAAAAAD////7AAAAAA=="} + ErrBadSequence = &FailedTransactionError{"AAAAAAAAAAD////7AAAAAA==", ""} ) // FailedTransactionError represent an error that occurred because @@ -24,6 +24,8 @@ var ( // encoded TransactionResult struct type FailedTransactionError struct { ResultXDR string + // DiagnosticEventsXDR is a base64-encoded []xdr.DiagnosticEvent + DiagnosticEventsXDR string } func (err *FailedTransactionError) Error() string { diff --git a/services/horizon/internal/txsub/submitter.go b/services/horizon/internal/txsub/submitter.go index 85fd858233..27ce85c87a 100644 --- a/services/horizon/internal/txsub/submitter.go +++ b/services/horizon/internal/txsub/submitter.go @@ -58,7 +58,7 @@ func (sub *submitter) Submit(ctx context.Context, env string) (result Submission switch cresp.Status { case proto.TXStatusError: - result.Err = &FailedTransactionError{cresp.Error} + result.Err = &FailedTransactionError{cresp.Error, cresp.DiagnosticEvents} case proto.TXStatusPending, proto.TXStatusDuplicate, proto.TXStatusTryAgainLater: //noop. A nil Err indicates success default: