@@ -3,15 +3,18 @@ package api_server
33import (
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.
3235type 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) {
7377func (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+ }
7786func (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