Skip to content

Commit 2516d09

Browse files
committed
Include txid compute tests
1 parent e6a8f1c commit 2516d09

1 file changed

Lines changed: 159 additions & 5 deletions

File tree

services/api_server/handlers_test.go

Lines changed: 159 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,18 @@ package api_server
33
import (
44
"bytes"
55
"context"
6+
"encoding/hex"
67
"encoding/json"
78
"errors"
89
"fmt"
910
"net/http"
1011
"net/http/httptest"
12+
"reflect"
1113
"sync"
1214
"testing"
1315
"time"
1416

17+
"github.com/bsv-blockchain/go-sdk/script"
1518
sdkTx "github.com/bsv-blockchain/go-sdk/transaction"
1619
"github.com/gin-gonic/gin"
1720
"go.uber.org/zap"
@@ -30,10 +33,11 @@ import (
3033
// end-to-end STUMP test fires deliveries concurrently to mirror
3134
// merkle-service's 64-worker delivery pool.
3235
type mockStore struct {
33-
mu sync.Mutex
34-
updateStatusCalls []*models.TransactionStatus
35-
stumps map[string]*models.Stump
36-
insertStumpErr error
36+
mu sync.Mutex
37+
updateStatusCalls []*models.TransactionStatus
38+
stumps map[string]*models.Stump
39+
insertStumpErr error
40+
insertedSubmissions []*models.Submission
3741
// insertStumpFn, if set, runs before the default record step and may
3842
// return an error to simulate per-key failures (Aerospike RECORD_TOO_BIG,
3943
// DEVICE_OVERLOAD, HOT_KEY, etc.). Returning non-nil skips the record.
@@ -73,7 +77,12 @@ func (m *mockStore) GetBUMP(context.Context, string) (uint64, []byte, error) {
7377
func (m *mockStore) SetMinedByTxIDs(context.Context, string, []string) ([]*models.TransactionStatus, error) {
7478
return nil, nil
7579
}
76-
func (m *mockStore) InsertSubmission(context.Context, *models.Submission) error { return nil }
80+
func (m *mockStore) InsertSubmission(_ context.Context, sub *models.Submission) error {
81+
m.mu.Lock()
82+
defer m.mu.Unlock()
83+
m.insertedSubmissions = append(m.insertedSubmissions, sub)
84+
return nil
85+
}
7786
func (m *mockStore) GetSubmissionsByTxID(context.Context, string) ([]*models.Submission, error) {
7887
return nil, nil
7988
}
@@ -707,3 +716,148 @@ func TestHandleCallback_FullBlockFlow_PartialStumpFailure(t *testing.T) {
707716
t.Errorf("expected 1 Kafka message after BLOCK_PROCESSED, got %d", got)
708717
}
709718
}
719+
720+
// makeRealTx returns a transaction with one funded input and one output so
721+
// that tx.EF() produces a different byte sequence than tx.Bytes(). Without
722+
// real source data, EF() returns ErrEmptyPreviousTx and the EF/legacy hashes
723+
// would collide — defeating the purpose of the EF-vs-legacy regression tests.
724+
func makeRealTx(t *testing.T) *sdkTx.Transaction {
725+
t.Helper()
726+
tx := sdkTx.NewTransaction()
727+
if err := tx.AddInputFrom(
728+
"0000000000000000000000000000000000000000000000000000000000000001",
729+
0,
730+
"76a914000000000000000000000000000000000000000088ac",
731+
1000,
732+
nil,
733+
); err != nil {
734+
t.Fatalf("AddInputFrom: %v", err)
735+
}
736+
opReturn, err := script.NewFromHex("6a")
737+
if err != nil {
738+
t.Fatalf("script.NewFromHex: %v", err)
739+
}
740+
tx.AddOutput(&sdkTx.TransactionOutput{Satoshis: 900, LockingScript: opReturn})
741+
return tx
742+
}
743+
744+
// TestHandleSubmitTransaction_TxID_IsCanonical verifies /tx records the
745+
// canonical Bitcoin txid (tx.TxID()) — not a hash of the wire bytes — for
746+
// every accepted content type and for both legacy and Extended Format
747+
// submissions. Regression test for the EF / canonical txid mismatch that
748+
// caused submissions.txid to never match transactions.txid, which broke SSE
749+
// status fan-out for any client posting EF.
750+
func TestHandleSubmitTransaction_TxID_IsCanonical(t *testing.T) {
751+
tx := makeRealTx(t)
752+
legacy := tx.Bytes()
753+
ef, err := tx.EF()
754+
if err != nil {
755+
t.Fatalf("tx.EF: %v", err)
756+
}
757+
canonical := tx.TxID().String()
758+
759+
if bytes.Equal(legacy, ef) {
760+
t.Fatalf("EF and legacy bytes are identical — test would be trivial")
761+
}
762+
763+
cases := []struct {
764+
name string
765+
contentType string
766+
body []byte
767+
}{
768+
{"octet-stream legacy", "application/octet-stream", legacy},
769+
{"octet-stream EF", "application/octet-stream", ef},
770+
{"text/plain hex legacy", "text/plain", []byte(hex.EncodeToString(legacy))},
771+
{"text/plain hex EF", "text/plain", []byte(hex.EncodeToString(ef))},
772+
{"json legacy", "application/json", mustMarshalJSON(t, map[string]string{"rawTx": hex.EncodeToString(legacy)})},
773+
{"json EF", "application/json", mustMarshalJSON(t, map[string]string{"rawTx": hex.EncodeToString(ef)})},
774+
}
775+
776+
for _, c := range cases {
777+
t.Run(c.name, func(t *testing.T) {
778+
broker := &kafka.RecordingBroker{}
779+
ms := &mockStore{}
780+
_, router := setupServerWithStore(broker, ms)
781+
782+
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/tx", bytes.NewReader(c.body))
783+
req.Header.Set("Content-Type", c.contentType)
784+
req.Header.Set("X-CallbackToken", "test-token")
785+
w := httptest.NewRecorder()
786+
router.ServeHTTP(w, req)
787+
788+
if w.Code != http.StatusAccepted {
789+
t.Fatalf("status %d: %s", w.Code, w.Body.String())
790+
}
791+
792+
if len(broker.Sends) != 1 {
793+
t.Fatalf("expected 1 Send, got %d", len(broker.Sends))
794+
}
795+
if broker.Sends[0].Key != canonical {
796+
t.Errorf("kafka key: want %s, got %s", canonical, broker.Sends[0].Key)
797+
}
798+
799+
if len(ms.insertedSubmissions) != 1 {
800+
t.Fatalf("expected 1 submission, got %d", len(ms.insertedSubmissions))
801+
}
802+
if ms.insertedSubmissions[0].TxID != canonical {
803+
t.Errorf("submission txid: want %s, got %s", canonical, ms.insertedSubmissions[0].TxID)
804+
}
805+
})
806+
}
807+
}
808+
809+
// TestHandleSubmitTransactions_TxID_IsCanonical is the bulk-endpoint
810+
// counterpart. Mixing legacy and EF in a single batch also confirms the
811+
// parser advances bytesUsed correctly across format changes.
812+
func TestHandleSubmitTransactions_TxID_IsCanonical(t *testing.T) {
813+
txA := makeRealTx(t)
814+
txB := makeRealTx(t)
815+
txB.Outputs[0].Satoshis = 800 // make B distinct so canonicals differ
816+
817+
legacyA := txA.Bytes()
818+
efB, err := txB.EF()
819+
if err != nil {
820+
t.Fatalf("EF: %v", err)
821+
}
822+
canonA := txA.TxID().String()
823+
canonB := txB.TxID().String()
824+
if canonA == canonB {
825+
t.Fatalf("test setup: txA and txB hashed equal")
826+
}
827+
828+
body := append([]byte{}, legacyA...)
829+
body = append(body, efB...)
830+
831+
broker := &kafka.RecordingBroker{}
832+
ms := &mockStore{}
833+
_, router := setupServerWithStore(broker, ms)
834+
835+
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/txs", bytes.NewReader(body))
836+
req.Header.Set("Content-Type", "application/octet-stream")
837+
req.Header.Set("X-CallbackToken", "test-token")
838+
w := httptest.NewRecorder()
839+
router.ServeHTTP(w, req)
840+
841+
if w.Code != http.StatusOK {
842+
t.Fatalf("status %d: %s", w.Code, w.Body.String())
843+
}
844+
845+
if len(broker.Batches) != 1 || len(broker.Batches[0]) != 2 {
846+
t.Fatalf("expected 1 batch of 2, got Batches=%v", broker.Batches)
847+
}
848+
if got := broker.Batches[0][0].Key; got != canonA {
849+
t.Errorf("batch[0]: want %s, got %s", canonA, got)
850+
}
851+
if got := broker.Batches[0][1].Key; got != canonB {
852+
t.Errorf("batch[1]: want %s, got %s", canonB, got)
853+
}
854+
855+
if len(ms.insertedSubmissions) != 2 {
856+
t.Fatalf("expected 2 submissions, got %d", len(ms.insertedSubmissions))
857+
}
858+
got := []string{ms.insertedSubmissions[0].TxID, ms.insertedSubmissions[1].TxID}
859+
want := []string{canonA, canonB}
860+
if !reflect.DeepEqual(got, want) {
861+
t.Errorf("submissions: want %v, got %v", want, got)
862+
}
863+
}

0 commit comments

Comments
 (0)