@@ -24,8 +24,10 @@ import (
2424 "io"
2525 "io/fs"
2626 "mime"
27+ "net"
2728 "net/http"
2829 "runtime/debug"
30+ "strings"
2931 "time"
3032
3133 ouroboros "github.com/blinklabs-io/gouroboros"
@@ -34,8 +36,8 @@ import (
3436 _ "github.com/blinklabs-io/tx-submit-api/docs" // docs is generated by Swag CLI
3537 "github.com/blinklabs-io/tx-submit-api/internal/config"
3638 "github.com/blinklabs-io/tx-submit-api/internal/logging"
39+ "github.com/blinklabs-io/tx-submit-api/internal/metrics"
3740 "github.com/blinklabs-io/tx-submit-api/submit"
38- "github.com/prometheus/client_golang/prometheus"
3941 "github.com/prometheus/client_golang/prometheus/promhttp"
4042 httpSwagger "github.com/swaggo/http-swagger"
4143)
@@ -47,23 +49,6 @@ var staticFS embed.FS
4749// submission. Cardano's protocol limit is ~16KB; 64KB gives ample overhead.
4850const maxTxBodyBytes = 64 * 1024
4951
50- var (
51- // Gauge to match input-output-hk's metric type
52- txSubmitFailCount = prometheus .NewGauge (prometheus.GaugeOpts {
53- Name : "tx_submit_fail_count" ,
54- Help : "transactions failed" ,
55- })
56- // Gauge to match input-output-hk's metric type
57- txSubmitCount = prometheus .NewGauge (prometheus.GaugeOpts {
58- Name : "tx_submit_count" ,
59- Help : "transactions submitted" ,
60- })
61- )
62-
63- func init () {
64- prometheus .MustRegister (txSubmitFailCount , txSubmitCount )
65- }
66-
6752// corsMiddleware adds CORS headers allowing all origins.
6853func corsMiddleware (next http.Handler ) http.Handler {
6954 return http .HandlerFunc (func (w http.ResponseWriter , r * http.Request ) {
@@ -228,6 +213,8 @@ func Start(cfg *config.Config) error {
228213 handler = recoveryMiddleware (handler )
229214 handler = corsMiddleware (handler )
230215
216+ metrics .Register ()
217+
231218 // Start metrics listener
232219 go func () {
233220 logger .Info ("starting metrics listener" ,
@@ -352,16 +339,17 @@ func handleHasTx(w http.ResponseWriter, r *http.Request) {
352339// @Failure 500 {object} string "Server Error"
353340// @Router /api/submit/tx [post]
354341func handleSubmitTx (w http.ResponseWriter , r * http.Request ) {
355- // First, initialize our configuration and loggers
356342 cfg := config .GetConfig ()
357343 logger := logging .GetLogger ()
344+ clientIP := realClientIP (r , cfg .Api .TrustedProxies )
358345
359- // Check our headers for content-type
346+ // Check our headers for content-type. Wrong content-type is rejected before
347+ // reading the body, so no IP metric is recorded here.
360348 mediaType , _ , _ := mime .ParseMediaType (r .Header .Get ("Content-Type" ))
361349 if mediaType != "application/cbor" {
362350 logger .Error ("invalid request body, should be application/cbor" )
363351 writeJSON (w , http .StatusUnsupportedMediaType , "invalid request body, should be application/cbor" )
364- txSubmitFailCount . Inc ()
352+ metrics . IncTxSubmitFailCount ()
365353 return
366354 }
367355
@@ -376,9 +364,18 @@ func handleSubmitTx(w http.ResponseWriter, r *http.Request) {
376364 logger .Error ("failed to read request body" , "err" , err )
377365 writeJSON (w , http .StatusInternalServerError , "failed to read request body" )
378366 }
379- txSubmitFailCount .Inc ()
367+ metrics .IncTxSubmitFailCount ()
368+ metrics .RecordTxRequest (clientIP , "error" )
380369 return
381370 }
371+
372+ // Parse tx content signals before submitting — best-effort, never blocks submission.
373+ txInfo , err := submit .ParseTxInfo (txRawBytes )
374+ if err != nil {
375+ logger .Warn ("failed to parse tx content signals" , "err" , err , "ip" , clientIP )
376+ txInfo = nil
377+ }
378+
382379 // Send TX
383380 errorChan := make (chan error , 1 )
384381 submitConfig := & submit.Config {
@@ -391,9 +388,10 @@ func handleSubmitTx(w http.ResponseWriter, r *http.Request) {
391388 }
392389 txHash , err := submit .SubmitTx (submitConfig , txRawBytes )
393390 if err != nil {
391+ var txRejectErr * localtxsubmission.TransactionRejectedError
392+ isRejected := errors .As (err , & txRejectErr )
394393 if r .Header .Get ("Accept" ) == "application/cbor" {
395- var txRejectErr * localtxsubmission.TransactionRejectedError
396- if errors .As (err , & txRejectErr ) {
394+ if isRejected && txRejectErr != nil {
397395 w .Header ().Set ("Content-Type" , "application/cbor" )
398396 w .WriteHeader (http .StatusBadRequest )
399397 _ , _ = w .Write (txRejectErr .ReasonCbor )
@@ -405,13 +403,25 @@ func handleSubmitTx(w http.ResponseWriter, r *http.Request) {
405403 } else {
406404 writeJSON (w , http .StatusBadRequest , err .Error ())
407405 }
408- txSubmitFailCount .Inc ()
406+ result := "error"
407+ if isRejected {
408+ result = "rejected"
409+ }
410+ metrics .IncTxSubmitFailCount ()
411+ metrics .RecordTxRequest (clientIP , result )
412+ if txInfo != nil {
413+ metrics .RecordTxContent (txInfo .ScriptType , txInfo .HasMinting , txInfo .HasReferenceInputs )
414+ }
409415 return
410416 }
411417
412418 // Node confirmed the tx is in its mempool (AcceptTx received synchronously).
413419 // Record success before responding so metrics always agree with the HTTP status.
414- txSubmitCount .Inc ()
420+ metrics .IncTxSubmitCount ()
421+ metrics .RecordTxRequest (clientIP , "accepted" )
422+ if txInfo != nil {
423+ metrics .RecordTxContent (txInfo .ScriptType , txInfo .HasMinting , txInfo .HasReferenceInputs )
424+ }
415425 writeJSON (w , http .StatusAccepted , txHash )
416426
417427 // Drain errorChan in the background. Post-submission connection errors do not
@@ -427,3 +437,46 @@ func handleSubmitTx(w http.ResponseWriter, r *http.Request) {
427437 }
428438 }()
429439}
440+
441+ // realClientIP extracts the client IP from the request. Forwarded headers
442+ // (X-Real-IP, X-Forwarded-For) are only trusted when the immediate peer
443+ // (r.RemoteAddr) is in the trustedProxies list; otherwise RemoteAddr is used
444+ // directly to prevent IP spoofing by untrusted callers.
445+ func realClientIP (r * http.Request , trustedProxies []string ) string {
446+ // Parse the immediate peer IP from RemoteAddr.
447+ peerHost , _ , err := net .SplitHostPort (r .RemoteAddr )
448+ if err != nil {
449+ peerHost = r .RemoteAddr
450+ }
451+ peerIP := net .ParseIP (peerHost )
452+
453+ // Only honour forwarded headers when the peer is a trusted proxy.
454+ if peerIP != nil {
455+ for _ , cidr := range trustedProxies {
456+ var trusted net.IP
457+ var network * net.IPNet
458+ if strings .Contains (cidr , "/" ) {
459+ _ , network , err = net .ParseCIDR (cidr )
460+ if err != nil {
461+ continue
462+ }
463+ } else {
464+ trusted = net .ParseIP (cidr )
465+ }
466+ inRange := (network != nil && network .Contains (peerIP )) ||
467+ (trusted != nil && trusted .Equal (peerIP ))
468+ if inRange {
469+ if ip := strings .TrimSpace (r .Header .Get ("X-Real-IP" )); ip != "" {
470+ return ip
471+ }
472+ if forwarded := r .Header .Get ("X-Forwarded-For" ); forwarded != "" {
473+ first , _ , _ := strings .Cut (forwarded , "," )
474+ return strings .TrimSpace (first )
475+ }
476+ break
477+ }
478+ }
479+ }
480+
481+ return peerHost
482+ }
0 commit comments