Skip to content
This repository has been archived by the owner on Oct 21, 2024. It is now read-only.

Commit

Permalink
Merge pull request #6 from bitcoin-sv/feat-batch-transactions
Browse files Browse the repository at this point in the history
feat(BUX-156): implement Multiple Submit Transaction
  • Loading branch information
arkadiuszos4chain authored Aug 9, 2023
2 parents 0063bb2 + 3b94345 commit 3be1b74
Show file tree
Hide file tree
Showing 12 changed files with 616 additions and 143 deletions.
35 changes: 35 additions & 0 deletions broadcast/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package broadcast

import (
"errors"
"fmt"
"strings"
)

var ErrClientUndefined = errors.New("client is undefined")
Expand All @@ -17,3 +19,36 @@ var ErrUnableToDecodeResponse = errors.New("unable to decode response")
var ErrMissingStatus = errors.New("missing tx status")

var ErrStrategyUnkown = errors.New("unknown strategy")

type ArcError struct {
Type string `json:"type"`
Title string `json:"title"`
Status int `json:"status"`
Detail string `json:"detail"`
Instance string `json:"instance,omitempty"`
Txid string `json:"txid,omitempty"`
ExtraInfo string `json:"extraInfo,omitempty"`
}

func (err ArcError) Error() string {
sb := strings.Builder{}

sb.WriteString("arc error: {")
sb.WriteString(fmt.Sprintf("type: %s, title: %s, status: %d, detail: %s",
err.Type, err.Title, err.Status, err.Detail))

if err.Instance != "" {
sb.Write([]byte(fmt.Sprintf(", instance: %s", err.Instance)))
}

if err.Txid != "" {
sb.Write([]byte(fmt.Sprintf(", txid: %s", err.Txid)))
}

if err.ExtraInfo != "" {
sb.Write([]byte(fmt.Sprintf(", extraInfo: %s", err.ExtraInfo)))
}

sb.WriteString("}")
return sb.String()
}
1 change: 1 addition & 0 deletions broadcast/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ type TransactionSubmitter interface {
}

type TransactionsSubmitter interface {
SubmitBatchTransactions(ctx context.Context, tx []*Transaction) ([]*SubmitTxResponse, error)
}

type Client interface {
Expand Down
254 changes: 254 additions & 0 deletions broadcast/internal/acceptance_tests/arc_submit_tx_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
package acceptancetests

import (
"context"
"errors"
"fmt"
"io"
"net/http"
"strings"
"testing"

"github.com/bitcoin-sv/go-broadcast-client/broadcast"
broadcast_client "github.com/bitcoin-sv/go-broadcast-client/broadcast/broadcast-client"
"github.com/bitcoin-sv/go-broadcast-client/broadcast/internal/arc"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)

var successfulSubmitResponse = `
{
"txStatus": "MINED"
}
`

var successfulSubmitBatchResponse = `
[
{
"txStatus": "MINED"
},
{
"txStatus": "MINED"
}
]
`

func TestSubmitTransaction(t *testing.T) {
t.Run("Should successfully submit transaction using first of two ArcClients", func(t *testing.T) {
// given
httpClientMock := &arc.MockHttpClient{}
broadcaster := broadcast_client.Builder().
WithHttpClient(httpClientMock).
WithArc(broadcast_client.ArcClientConfig{APIUrl: "http://arc1-api-url", Token: "arc1-token"}).
WithArc(broadcast_client.ArcClientConfig{APIUrl: "http://arc2-api-url", Token: "arc2-token"}).
Build()

httpResponse1 := &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(successfulSubmitResponse))}
httpResponse2 := &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(successfulSubmitResponse))}

httpClientMock.On("DoRequest", mock.Anything, mock.Anything).Return(httpResponse1, nil).Once()
// first miner responded successfully, next one should be skipped
httpClientMock.On("DoRequest", mock.Anything, mock.Anything).Return(httpResponse2, nil).Times(0)

// when
result, err := broadcaster.SubmitTransaction(context.Background(), &broadcast.Transaction{RawTx: "transaction-data"})

// then
httpClientMock.AssertExpectations(t)

assert.NoError(t, err)
assert.NotNil(t, result)
})

t.Run("Should return error if both ArcClients return errors", func(t *testing.T) {
// given
httpClientMock := &arc.MockHttpClient{}
broadcaster := broadcast_client.Builder().
WithHttpClient(httpClientMock).
WithArc(broadcast_client.ArcClientConfig{APIUrl: "http://arc1-api-url", Token: "arc1-token"}).
WithArc(broadcast_client.ArcClientConfig{APIUrl: "http://arc2-api-url", Token: "arc2-token"}).
Build()

httpResponse := &http.Response{}
httpClientMock.On("DoRequest", mock.Anything, mock.Anything).Return(httpResponse, errors.New("http error")).Once()
httpClientMock.On("DoRequest", mock.Anything, mock.Anything).Return(httpResponse, errors.New("http error")).Once()

// when
result, err := broadcaster.SubmitTransaction(context.Background(), &broadcast.Transaction{RawTx: "transaction-data"})

// then
httpClientMock.AssertExpectations(t)

assert.Error(t, err)
assert.Nil(t, result)
assert.EqualError(t, err, broadcast.ErrAllBroadcastersFailed.Error())
})

t.Run("Should return error if one ArcClient returns error and the other returns invalid response", func(t *testing.T) {
// given
httpClientMock := &arc.MockHttpClient{}
broadcaster := broadcast_client.Builder().
WithHttpClient(httpClientMock).
WithArc(broadcast_client.ArcClientConfig{APIUrl: "http://arc1-api-url", Token: "arc1-token"}).
WithArc(broadcast_client.ArcClientConfig{APIUrl: "http://arc2-api-url", Token: "arc2-token"}).
Build()

httpResponse := &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("invalid-response"))}
httpClientMock.On("DoRequest", mock.Anything, mock.Anything).Return(httpResponse, nil).Once()
httpClientMock.On("DoRequest", mock.Anything, mock.Anything).Return(httpResponse, errors.New("http error")).Once()

// when
result, err := broadcaster.SubmitTransaction(context.Background(), &broadcast.Transaction{RawTx: "transaction-data"})

// then
httpClientMock.AssertExpectations(t)

assert.Error(t, err)
assert.Nil(t, result)
})

t.Run("Should successfully submit transaction if one ArcClient returns missing status and the other is successful", func(t *testing.T) {
// given
httpClientMock := &arc.MockHttpClient{}
broadcaster := broadcast_client.Builder().
WithHttpClient(httpClientMock).
WithArc(broadcast_client.ArcClientConfig{APIUrl: "http://arc1-api-url", Token: "arc1-token"}).
WithArc(broadcast_client.ArcClientConfig{APIUrl: "http://arc2-api-url", Token: "arc2-token"}).
Build()

httpResponse1 := &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"blockHash": "hash"}`))}
httpResponse2 := &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(successfulSubmitResponse))}
httpClientMock.On("DoRequest", mock.Anything, mock.Anything).Return(httpResponse1, nil).Once()
httpClientMock.On("DoRequest", mock.Anything, mock.Anything).Return(httpResponse2, nil).Once()

// when
result, err := broadcaster.SubmitTransaction(context.Background(), &broadcast.Transaction{RawTx: "transaction-data"})

// then
httpClientMock.AssertExpectations(t)

assert.NoError(t, err)
assert.NotNil(t, result)
})
}

func TestSubmitBatchTransactions(t *testing.T) {
t.Run("Should successfully submit batch of transactions using first of few ArcClients", func(t *testing.T) {
// given
httpClientMock := &arc.MockHttpClient{}
broadcaster := buildBroadcastClient(2, httpClientMock)

httpResponse1 := &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(successfulSubmitBatchResponse))}
httpResponse2 := &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(successfulSubmitBatchResponse))}

httpClientMock.On("DoRequest", mock.Anything, mock.Anything).Return(httpResponse1, nil).Once()
// first miner responded successfully, next one should be skipped
httpClientMock.On("DoRequest", mock.Anything, mock.Anything).Return(httpResponse2, nil).Times(0)

batch := []*broadcast.Transaction{
{RawTx: "transaction-0-data"},
{RawTx: "transaction-1-data"},
}

// when
result, err := broadcaster.SubmitBatchTransactions(context.Background(), batch)

// then
httpClientMock.AssertExpectations(t)

assert.NoError(t, err)
assert.NotNil(t, result)
})

t.Run("Should successfully submit batch of transactions if one ArcClient returns missing status and the other is successful", func(t *testing.T) {
// given
httpClientMock := &arc.MockHttpClient{}
broadcaster := buildBroadcastClient(2, httpClientMock)

// response without status field
httpResponse1 := &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`[{"blockHash": "hash"}, {"txStatus": "MINED"}`))}
// valid response
httpResponse2 := &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(successfulSubmitBatchResponse))}

httpClientMock.On("DoRequest", mock.Anything, mock.Anything).Return(httpResponse1, nil).Once()
httpClientMock.On("DoRequest", mock.Anything, mock.Anything).Return(httpResponse2, nil).Once()

batch := []*broadcast.Transaction{
{RawTx: "transaction-0-data"},
{RawTx: "transaction-1-data"},
}

// when
result, err := broadcaster.SubmitBatchTransactions(context.Background(), batch)

// then
httpClientMock.AssertExpectations(t)

assert.NoError(t, err)
assert.NotNil(t, result)
})

t.Run("Should return error if every ArcClients return errors", func(t *testing.T) {
// given
httpClientMock := &arc.MockHttpClient{}
broadcaster := buildBroadcastClient(2, httpClientMock)

httpResponse := &http.Response{}
httpClientMock.On("DoRequest", mock.Anything, mock.Anything).Return(httpResponse, errors.New("http error")).Once()
httpClientMock.On("DoRequest", mock.Anything, mock.Anything).Return(httpResponse, errors.New("http error")).Once()

batch := []*broadcast.Transaction{
{RawTx: "transaction-0-data"},
{RawTx: "transaction-1-data"},
}

// when
result, err := broadcaster.SubmitBatchTransactions(context.Background(), batch)

// then
httpClientMock.AssertExpectations(t)

assert.Error(t, err)
assert.Nil(t, result)
assert.EqualError(t, err, broadcast.ErrAllBroadcastersFailed.Error())
})

t.Run("Should return error if one ArcClient returns error and the other returns invalid response", func(t *testing.T) {
// given
httpClientMock := &arc.MockHttpClient{}
broadcaster := buildBroadcastClient(2, httpClientMock)

httpResponse := &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("invalid-response"))}
httpClientMock.On("DoRequest", mock.Anything, mock.Anything).Return(httpResponse, nil).Once()
httpClientMock.On("DoRequest", mock.Anything, mock.Anything).Return(httpResponse, errors.New("http error")).Once()

batch := []*broadcast.Transaction{
{RawTx: "transaction-0-data"},
{RawTx: "transaction-1-data"},
}

// when
result, err := broadcaster.SubmitBatchTransactions(context.Background(), batch)

// then
httpClientMock.AssertExpectations(t)

assert.Error(t, err)
assert.Nil(t, result)
})
}

func buildBroadcastClient(acrClients uint, httpClient *arc.MockHttpClient) broadcast.Client {
builder := broadcast_client.Builder().
WithHttpClient(httpClient)

for i := uint(0); i < acrClients; i++ {
arc := broadcast_client.ArcClientConfig{
APIUrl: fmt.Sprintf("http://arc%d-api-url", i),
Token: fmt.Sprintf("arc%d-token", i),
}
builder = builder.WithArc(arc)
}

return builder.Build()
}
Loading

0 comments on commit 3be1b74

Please sign in to comment.