From 9344f16335fcc7a76a53c99aa0c52d26ef584548 Mon Sep 17 00:00:00 2001 From: David Case Date: Wed, 18 Feb 2026 00:02:11 +0000 Subject: [PATCH 01/11] Add ARC-compatible status code to TransactionStatus responses The go-sdk's ArcResponse expects a `status` int field in the JSON body representing the HTTP status code. Clients like 1sat-indexer rely on this to distinguish 200 vs 404 responses when querying transaction status. Co-Authored-By: Claude Opus 4.6 --- handlers/webhook.go | 1 + models/transaction.go | 1 + routes/fiber/routes.go | 5 +++++ 3 files changed, 7 insertions(+) diff --git a/handlers/webhook.go b/handlers/webhook.go index 907c50f..ae0a975 100644 --- a/handlers/webhook.go +++ b/handlers/webhook.go @@ -161,6 +161,7 @@ func (h *WebhookHandler) deliverWebhook(ctx context.Context, sub models.Submissi slog.String("url", sub.CallbackURL), slog.String("status", string(status.Status))) + status.StatusCode = http.StatusOK payloadBytes, err := json.Marshal(status) if err != nil { h.logger.Error("Failed to marshal payload", diff --git a/models/transaction.go b/models/transaction.go index aabafae..6b04b71 100644 --- a/models/transaction.go +++ b/models/transaction.go @@ -39,6 +39,7 @@ func (h *HexBytes) UnmarshalJSON(data []byte) error { type TransactionStatus struct { TxID string `json:"txid"` Status Status `json:"txStatus"` + StatusCode int `json:"status,omitempty"` Timestamp time.Time `json:"timestamp"` BlockHash string `json:"blockHash,omitempty"` BlockHeight uint64 `json:"blockHeight,omitempty"` diff --git a/routes/fiber/routes.go b/routes/fiber/routes.go index 8e0ab5a..945d7eb 100644 --- a/routes/fiber/routes.go +++ b/routes/fiber/routes.go @@ -127,6 +127,7 @@ func (r *Routes) handlePostTx(c *fiber.Ctx) error { return r.handleSubmitError(c, err) } + status.StatusCode = http.StatusOK return c.JSON(status) } @@ -173,6 +174,9 @@ func (r *Routes) handlePostTxs(c *fiber.Ctx) error { return r.handleSubmitError(c, err) } + for _, s := range statuses { + s.StatusCode = http.StatusOK + } return c.JSON(statuses) } @@ -211,6 +215,7 @@ func (r *Routes) handleGetTx(c *fiber.Ctx) error { } return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to get status"}) } + status.StatusCode = http.StatusOK return c.JSON(status) } From ec713752db74ff88491358ebf889d345165ccd4e Mon Sep 17 00:00:00 2001 From: David Case Date: Wed, 18 Feb 2026 00:16:22 +0000 Subject: [PATCH 02/11] Use ErrorFields for 404 response in handleGetTx Ensures the JSON body includes `"status": 404` so ARC clients can detect not-found via the response body, not just the HTTP status code. Co-Authored-By: Claude Opus 4.6 --- routes/fiber/routes.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routes/fiber/routes.go b/routes/fiber/routes.go index 945d7eb..7886689 100644 --- a/routes/fiber/routes.go +++ b/routes/fiber/routes.go @@ -211,7 +211,7 @@ func (r *Routes) handleGetTx(c *fiber.Ctx) error { status, err := r.service.GetStatus(c.UserContext(), c.Params("txid")) if err != nil { if strings.Contains(err.Error(), "not found") { - return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "Transaction not found"}) + return c.Status(http.StatusNotFound).JSON(arcerrors.NewErrorFields(arcerrors.StatusNotFound, "Transaction not found")) } return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to get status"}) } From 51baf5815e1ea86ea054c2e517d3850b965dc4fe Mon Sep 17 00:00:00 2001 From: David Case Date: Wed, 18 Feb 2026 00:21:06 +0000 Subject: [PATCH 03/11] Fix swagger annotations and regenerate docs Update ErrorFields references from errors.ErrorFields to arcerrors.ErrorFields in swagger annotations, and regenerate docs to include new status field. Co-Authored-By: Claude Opus 4.6 --- docs/docs.go | 20 +- docs/swagger.json | 410 ++++++++++++++++++++--------------------- docs/swagger.yaml | 219 +++++++++++----------- routes/fiber/routes.go | 6 +- 4 files changed, 328 insertions(+), 327 deletions(-) diff --git a/docs/docs.go b/docs/docs.go index c87c5f8..4bb639b 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -1,6 +1,4 @@ // Package docs Code generated by swaggo/swag. DO NOT EDIT -// -//nolint:gochecknoglobals,gochecknoinits // generated file package docs import "github.com/swaggo/swag" @@ -164,13 +162,13 @@ const docTemplate = `{ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/errors.ErrorFields" + "$ref": "#/definitions/arcerrors.ErrorFields" } }, "465": { "description": "ARC validation error", "schema": { - "$ref": "#/definitions/errors.ErrorFields" + "$ref": "#/definitions/arcerrors.ErrorFields" } }, "500": { @@ -214,10 +212,7 @@ const docTemplate = `{ "404": { "description": "Not Found", "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "$ref": "#/definitions/arcerrors.ErrorFields" } }, "500": { @@ -302,13 +297,13 @@ const docTemplate = `{ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/errors.ErrorFields" + "$ref": "#/definitions/arcerrors.ErrorFields" } }, "465": { "description": "ARC validation error", "schema": { - "$ref": "#/definitions/errors.ErrorFields" + "$ref": "#/definitions/arcerrors.ErrorFields" } } } @@ -316,7 +311,7 @@ const docTemplate = `{ } }, "definitions": { - "errors.ErrorFields": { + "arcerrors.ErrorFields": { "type": "object", "properties": { "detail": { @@ -414,6 +409,9 @@ const docTemplate = `{ "type": "integer" } }, + "status": { + "type": "integer" + }, "timestamp": { "type": "string" }, diff --git a/docs/swagger.json b/docs/swagger.json index 1c8de62..9dc5e85 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -1,144 +1,38 @@ { - "basePath": "/", - "definitions": { - "errors.ErrorFields": { - "properties": { - "detail": { - "type": "string" - }, - "extraInfo": { - "type": "string" - }, - "status": { - "type": "integer" - }, - "title": { - "type": "string" - }, - "type": { - "type": "string" - } - }, - "type": "object" - }, - "fiber.TransactionRequest": { - "properties": { - "rawTx": { - "example": "0100000001...", - "type": "string" - } - }, - "type": "object" - }, - "models.Policy": { - "properties": { - "maxscriptsizepolicy": { - "type": "integer" - }, - "maxtxsigopscountspolicy": { - "type": "integer" - }, - "maxtxsizepolicy": { - "type": "integer" - }, - "miningFeeBytes": { - "type": "integer" - }, - "miningFeeSatoshis": { - "type": "integer" - } - }, - "type": "object" - }, - "models.Status": { - "enum": [ - "UNKNOWN", - "RECEIVED", - "SENT_TO_NETWORK", - "ACCEPTED_BY_NETWORK", - "SEEN_ON_NETWORK", - "DOUBLE_SPEND_ATTEMPTED", - "REJECTED", - "MINED", - "IMMUTABLE" - ], - "type": "string", - "x-enum-varnames": [ - "StatusUnknown", - "StatusReceived", - "StatusSentToNetwork", - "StatusAcceptedByNetwork", - "StatusSeenOnNetwork", - "StatusDoubleSpendAttempted", - "StatusRejected", - "StatusMined", - "StatusImmutable" - ] - }, - "models.TransactionStatus": { - "properties": { - "blockHash": { - "type": "string" - }, - "blockHeight": { - "type": "integer" - }, - "competingTxs": { - "items": { - "type": "string" - }, - "type": "array" - }, - "extraInfo": { - "type": "string" - }, - "merklePath": { - "items": { - "type": "integer" - }, - "type": "array" - }, - "timestamp": { - "type": "string" - }, - "txStatus": { - "$ref": "#/definitions/models.Status" - }, - "txid": { - "type": "string" - } - }, - "type": "object" - } - }, + "swagger": "2.0", "info": { + "description": "BSV transaction broadcast service with ARC-compatible endpoints.", + "title": "Arcade API", "contact": { "name": "BSV Blockchain", "url": "https://github.com/bsv-blockchain/arcade" }, - "description": "BSV transaction broadcast service with ARC-compatible endpoints.", "license": { "name": "Open BSV License", "url": "https://github.com/bsv-blockchain/arcade/blob/main/LICENSE" }, - "title": "Arcade API", "version": "0.1.0" }, + "basePath": "/", "paths": { "/events": { "get": { "description": "Server-Sent Events stream of transaction status updates. If callbackToken is provided, only events for that token are streamed.", + "produces": [ + "text/event-stream" + ], + "tags": [ + "arcade" + ], + "summary": "Stream transaction events", "parameters": [ { + "type": "string", "description": "Callback token from transaction submission", - "in": "query", "name": "callbackToken", - "type": "string" + "in": "query" } ], - "produces": [ - "text/event-stream" - ], "responses": { "200": { "description": "SSE stream of transaction status updates", @@ -146,11 +40,7 @@ "type": "string" } } - }, - "summary": "Stream transaction events", - "tags": [ - "arcade" - ] + } } }, "/health": { @@ -159,6 +49,10 @@ "produces": [ "text/plain" ], + "tags": [ + "arcade" + ], + "summary": "Health check", "responses": { "200": { "description": "OK", @@ -172,11 +66,7 @@ "type": "string" } } - }, - "summary": "Health check", - "tags": [ - "arcade" - ] + } } }, "/policy": { @@ -185,6 +75,10 @@ "produces": [ "application/json" ], + "tags": [ + "arcade" + ], + "summary": "Get policy", "responses": { "200": { "description": "OK", @@ -192,65 +86,65 @@ "$ref": "#/definitions/models.Policy" } } - }, - "summary": "Get policy", - "tags": [ - "arcade" - ] + } } }, "/tx": { "post": { + "description": "Submit a single transaction for broadcast. Accepts raw transaction bytes, hex string, or JSON with rawTx field.", "consumes": [ "application/json", "application/octet-stream", "text/plain" ], - "description": "Submit a single transaction for broadcast. Accepts raw transaction bytes, hex string, or JSON with rawTx field.", + "produces": [ + "application/json" + ], + "tags": [ + "arcade" + ], + "summary": "Submit transaction", "parameters": [ { "description": "Transaction data", - "in": "body", "name": "transaction", + "in": "body", "required": true, "schema": { "$ref": "#/definitions/fiber.TransactionRequest" } }, { + "type": "string", "description": "URL for status callbacks", - "in": "header", "name": "X-CallbackUrl", - "type": "string" + "in": "header" }, { + "type": "string", "description": "Token for SSE event filtering", - "in": "header", "name": "X-CallbackToken", - "type": "string" + "in": "header" }, { + "type": "string", "description": "Send all status updates (true/false)", - "in": "header", "name": "X-FullStatusUpdates", - "type": "string" + "in": "header" }, { + "type": "string", "description": "Skip fee validation (true/false)", - "in": "header", "name": "X-SkipFeeValidation", - "type": "string" + "in": "header" }, { + "type": "string", "description": "Skip script validation (true/false)", - "in": "header", "name": "X-SkipScriptValidation", - "type": "string" + "in": "header" } ], - "produces": [ - "application/json" - ], "responses": { "200": { "description": "OK", @@ -261,46 +155,46 @@ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/errors.ErrorFields" + "$ref": "#/definitions/arcerrors.ErrorFields" } }, "465": { "description": "ARC validation error", "schema": { - "$ref": "#/definitions/errors.ErrorFields" + "$ref": "#/definitions/arcerrors.ErrorFields" } }, "500": { "description": "Internal Server Error", "schema": { + "type": "object", "additionalProperties": { "type": "string" - }, - "type": "object" + } } } - }, - "summary": "Submit transaction", - "tags": [ - "arcade" - ] + } } }, "/tx/{txid}": { "get": { "description": "Get the current status of a submitted transaction", + "produces": [ + "application/json" + ], + "tags": [ + "arcade" + ], + "summary": "Get transaction status", "parameters": [ { + "type": "string", "description": "Transaction ID", - "in": "path", "name": "txid", - "required": true, - "type": "string" + "in": "path", + "required": true } ], - "produces": [ - "application/json" - ], "responses": { "200": { "description": "OK", @@ -311,118 +205,224 @@ "404": { "description": "Not Found", "schema": { - "additionalProperties": { - "type": "string" - }, - "type": "object" + "$ref": "#/definitions/arcerrors.ErrorFields" } }, "500": { "description": "Internal Server Error", "schema": { + "type": "object", "additionalProperties": { "type": "string" - }, - "type": "object" + } } } - }, - "summary": "Get transaction status", - "tags": [ - "arcade" - ] + } } }, "/txs": { "post": { + "description": "Submit multiple transactions for broadcast", "consumes": [ "application/json" ], - "description": "Submit multiple transactions for broadcast", + "produces": [ + "application/json" + ], + "tags": [ + "arcade" + ], + "summary": "Submit multiple transactions", "parameters": [ { "description": "Array of transactions", - "in": "body", "name": "transactions", + "in": "body", "required": true, "schema": { + "type": "array", "items": { "$ref": "#/definitions/fiber.TransactionRequest" - }, - "type": "array" + } } }, { + "type": "string", "description": "URL for status callbacks", - "in": "header", "name": "X-CallbackUrl", - "type": "string" + "in": "header" }, { + "type": "string", "description": "Token for SSE event filtering", - "in": "header", "name": "X-CallbackToken", - "type": "string" + "in": "header" }, { + "type": "string", "description": "Send all status updates (true/false)", - "in": "header", "name": "X-FullStatusUpdates", - "type": "string" + "in": "header" }, { + "type": "string", "description": "Skip fee validation (true/false)", - "in": "header", "name": "X-SkipFeeValidation", - "type": "string" + "in": "header" }, { + "type": "string", "description": "Skip script validation (true/false)", - "in": "header", "name": "X-SkipScriptValidation", - "type": "string" + "in": "header" } ], - "produces": [ - "application/json" - ], "responses": { "200": { "description": "OK", "schema": { + "type": "array", "items": { "$ref": "#/definitions/models.TransactionStatus" - }, - "type": "array" + } } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/errors.ErrorFields" + "$ref": "#/definitions/arcerrors.ErrorFields" } }, "465": { "description": "ARC validation error", "schema": { - "$ref": "#/definitions/errors.ErrorFields" + "$ref": "#/definitions/arcerrors.ErrorFields" } } + } + } + } + }, + "definitions": { + "arcerrors.ErrorFields": { + "type": "object", + "properties": { + "detail": { + "type": "string" }, - "summary": "Submit multiple transactions", - "tags": [ - "arcade" - ] + "extraInfo": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "fiber.TransactionRequest": { + "type": "object", + "properties": { + "rawTx": { + "type": "string", + "example": "0100000001..." + } + } + }, + "models.Policy": { + "type": "object", + "properties": { + "maxscriptsizepolicy": { + "type": "integer" + }, + "maxtxsigopscountspolicy": { + "type": "integer" + }, + "maxtxsizepolicy": { + "type": "integer" + }, + "miningFeeBytes": { + "type": "integer" + }, + "miningFeeSatoshis": { + "type": "integer" + } + } + }, + "models.Status": { + "type": "string", + "enum": [ + "UNKNOWN", + "RECEIVED", + "SENT_TO_NETWORK", + "ACCEPTED_BY_NETWORK", + "SEEN_ON_NETWORK", + "DOUBLE_SPEND_ATTEMPTED", + "REJECTED", + "MINED", + "IMMUTABLE" + ], + "x-enum-varnames": [ + "StatusUnknown", + "StatusReceived", + "StatusSentToNetwork", + "StatusAcceptedByNetwork", + "StatusSeenOnNetwork", + "StatusDoubleSpendAttempted", + "StatusRejected", + "StatusMined", + "StatusImmutable" + ] + }, + "models.TransactionStatus": { + "type": "object", + "properties": { + "blockHash": { + "type": "string" + }, + "blockHeight": { + "type": "integer" + }, + "competingTxs": { + "type": "array", + "items": { + "type": "string" + } + }, + "extraInfo": { + "type": "string" + }, + "merklePath": { + "type": "array", + "items": { + "type": "integer" + } + }, + "status": { + "type": "integer" + }, + "timestamp": { + "type": "string" + }, + "txStatus": { + "$ref": "#/definitions/models.Status" + }, + "txid": { + "type": "string" + } } } }, "securityDefinitions": { "BearerAuth": { "description": "Bearer token authentication", - "in": "header", + "type": "apiKey", "name": "Authorization", - "type": "apiKey" + "in": "header" } - }, - "swagger": "2.0" -} + } +} \ No newline at end of file diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 38e296d..0c119c1 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1,6 +1,6 @@ basePath: / definitions: - errors.ErrorFields: + arcerrors.ErrorFields: properties: detail: type: string @@ -34,26 +34,26 @@ definitions: type: object models.Status: enum: - - UNKNOWN - - RECEIVED - - SENT_TO_NETWORK - - ACCEPTED_BY_NETWORK - - SEEN_ON_NETWORK - - DOUBLE_SPEND_ATTEMPTED - - REJECTED - - MINED - - IMMUTABLE + - UNKNOWN + - RECEIVED + - SENT_TO_NETWORK + - ACCEPTED_BY_NETWORK + - SEEN_ON_NETWORK + - DOUBLE_SPEND_ATTEMPTED + - REJECTED + - MINED + - IMMUTABLE type: string x-enum-varnames: - - StatusUnknown - - StatusReceived - - StatusSentToNetwork - - StatusAcceptedByNetwork - - StatusSeenOnNetwork - - StatusDoubleSpendAttempted - - StatusRejected - - StatusMined - - StatusImmutable + - StatusUnknown + - StatusReceived + - StatusSentToNetwork + - StatusAcceptedByNetwork + - StatusSeenOnNetwork + - StatusDoubleSpendAttempted + - StatusRejected + - StatusMined + - StatusImmutable models.TransactionStatus: properties: blockHash: @@ -70,6 +70,8 @@ definitions: items: type: integer type: array + status: + type: integer timestamp: type: string txStatus: @@ -90,14 +92,15 @@ info: paths: /events: get: - description: Server-Sent Events stream of transaction status updates. If callbackToken is provided, only events for that token are streamed. + description: Server-Sent Events stream of transaction status updates. If callbackToken + is provided, only events for that token are streamed. parameters: - - description: Callback token from transaction submission - in: query - name: callbackToken - type: string + - description: Callback token from transaction submission + in: query + name: callbackToken + type: string produces: - - text/event-stream + - text/event-stream responses: "200": description: SSE stream of transaction status updates @@ -105,12 +108,12 @@ paths: type: string summary: Stream transaction events tags: - - arcade + - arcade /health: get: description: Returns the health status of the service produces: - - text/plain + - text/plain responses: "200": description: OK @@ -122,12 +125,13 @@ paths: type: string summary: Health check tags: - - arcade + - arcade /policy: get: - description: Returns the transaction policy configuration including fee rates and limits + description: Returns the transaction policy configuration including fee rates + and limits produces: - - application/json + - application/json responses: "200": description: OK @@ -135,43 +139,44 @@ paths: $ref: '#/definitions/models.Policy' summary: Get policy tags: - - arcade + - arcade /tx: post: consumes: - - application/json - - application/octet-stream - - text/plain - description: Submit a single transaction for broadcast. Accepts raw transaction bytes, hex string, or JSON with rawTx field. + - application/json + - application/octet-stream + - text/plain + description: Submit a single transaction for broadcast. Accepts raw transaction + bytes, hex string, or JSON with rawTx field. parameters: - - description: Transaction data - in: body - name: transaction - required: true - schema: - $ref: '#/definitions/fiber.TransactionRequest' - - description: URL for status callbacks - in: header - name: X-CallbackUrl - type: string - - description: Token for SSE event filtering - in: header - name: X-CallbackToken - type: string - - description: Send all status updates (true/false) - in: header - name: X-FullStatusUpdates - type: string - - description: Skip fee validation (true/false) - in: header - name: X-SkipFeeValidation - type: string - - description: Skip script validation (true/false) - in: header - name: X-SkipScriptValidation - type: string + - description: Transaction data + in: body + name: transaction + required: true + schema: + $ref: '#/definitions/fiber.TransactionRequest' + - description: URL for status callbacks + in: header + name: X-CallbackUrl + type: string + - description: Token for SSE event filtering + in: header + name: X-CallbackToken + type: string + - description: Send all status updates (true/false) + in: header + name: X-FullStatusUpdates + type: string + - description: Skip fee validation (true/false) + in: header + name: X-SkipFeeValidation + type: string + - description: Skip script validation (true/false) + in: header + name: X-SkipScriptValidation + type: string produces: - - application/json + - application/json responses: "200": description: OK @@ -180,11 +185,11 @@ paths: "400": description: Bad Request schema: - $ref: '#/definitions/errors.ErrorFields' + $ref: '#/definitions/arcerrors.ErrorFields' "465": description: ARC validation error schema: - $ref: '#/definitions/errors.ErrorFields' + $ref: '#/definitions/arcerrors.ErrorFields' "500": description: Internal Server Error schema: @@ -193,18 +198,18 @@ paths: type: object summary: Submit transaction tags: - - arcade + - arcade /tx/{txid}: get: description: Get the current status of a submitted transaction parameters: - - description: Transaction ID - in: path - name: txid - required: true - type: string + - description: Transaction ID + in: path + name: txid + required: true + type: string produces: - - application/json + - application/json responses: "200": description: OK @@ -213,9 +218,7 @@ paths: "404": description: Not Found schema: - additionalProperties: - type: string - type: object + $ref: '#/definitions/arcerrors.ErrorFields' "500": description: Internal Server Error schema: @@ -224,43 +227,43 @@ paths: type: object summary: Get transaction status tags: - - arcade + - arcade /txs: post: consumes: - - application/json + - application/json description: Submit multiple transactions for broadcast parameters: - - description: Array of transactions - in: body - name: transactions - required: true - schema: - items: - $ref: '#/definitions/fiber.TransactionRequest' - type: array - - description: URL for status callbacks - in: header - name: X-CallbackUrl - type: string - - description: Token for SSE event filtering - in: header - name: X-CallbackToken - type: string - - description: Send all status updates (true/false) - in: header - name: X-FullStatusUpdates - type: string - - description: Skip fee validation (true/false) - in: header - name: X-SkipFeeValidation - type: string - - description: Skip script validation (true/false) - in: header - name: X-SkipScriptValidation - type: string + - description: Array of transactions + in: body + name: transactions + required: true + schema: + items: + $ref: '#/definitions/fiber.TransactionRequest' + type: array + - description: URL for status callbacks + in: header + name: X-CallbackUrl + type: string + - description: Token for SSE event filtering + in: header + name: X-CallbackToken + type: string + - description: Send all status updates (true/false) + in: header + name: X-FullStatusUpdates + type: string + - description: Skip fee validation (true/false) + in: header + name: X-SkipFeeValidation + type: string + - description: Skip script validation (true/false) + in: header + name: X-SkipScriptValidation + type: string produces: - - application/json + - application/json responses: "200": description: OK @@ -271,14 +274,14 @@ paths: "400": description: Bad Request schema: - $ref: '#/definitions/errors.ErrorFields' + $ref: '#/definitions/arcerrors.ErrorFields' "465": description: ARC validation error schema: - $ref: '#/definitions/errors.ErrorFields' + $ref: '#/definitions/arcerrors.ErrorFields' summary: Submit multiple transactions tags: - - arcade + - arcade securityDefinitions: BearerAuth: description: Bearer token authentication diff --git a/routes/fiber/routes.go b/routes/fiber/routes.go index 7886689..b600e56 100644 --- a/routes/fiber/routes.go +++ b/routes/fiber/routes.go @@ -106,7 +106,7 @@ func (r *Routes) handleGetPolicy(c *fiber.Ctx) error { // @Param X-SkipScriptValidation header string false "Skip script validation (true/false)" // @Success 200 {object} models.TransactionStatus // @Failure 400 {object} arcerrors.ErrorFields -// @Failure 465 {object} errors.ErrorFields "ARC validation error" +// @Failure 465 {object} arcerrors.ErrorFields "ARC validation error" // @Failure 500 {object} map[string]string // @Router /tx [post] func (r *Routes) handlePostTx(c *fiber.Ctx) error { @@ -145,7 +145,7 @@ func (r *Routes) handlePostTx(c *fiber.Ctx) error { // @Param X-SkipScriptValidation header string false "Skip script validation (true/false)" // @Success 200 {array} models.TransactionStatus // @Failure 400 {object} arcerrors.ErrorFields -// @Failure 465 {object} errors.ErrorFields "ARC validation error" +// @Failure 465 {object} arcerrors.ErrorFields "ARC validation error" // @Router /txs [post] func (r *Routes) handlePostTxs(c *fiber.Ctx) error { ctx := c.UserContext() @@ -204,7 +204,7 @@ func (r *Routes) handleSubmitError(c *fiber.Ctx, err error) error { // @Produce json // @Param txid path string true "Transaction ID" // @Success 200 {object} models.TransactionStatus -// @Failure 404 {object} map[string]string +// @Failure 404 {object} arcerrors.ErrorFields // @Failure 500 {object} map[string]string // @Router /tx/{txid} [get] func (r *Routes) handleGetTx(c *fiber.Ctx) error { From 238ee694e616e343bbce0049bdbbc84f1b73be58 Mon Sep 17 00:00:00 2001 From: David Case Date: Wed, 4 Mar 2026 12:44:18 -0500 Subject: [PATCH 04/11] fix: don't close externally-provided P2P client on shutdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a P2P client is passed into Arcade's Initialize(), Arcade should not close it during Services.Close() — the caller owns it. The ownsP2PClient guard was already used during initialization error cleanup but was missing from the Close() method, causing a double-close panic when the caller also closed the shared client. --- config/services.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/config/services.go b/config/services.go index ca5f40f..5c1b53f 100644 --- a/config/services.go +++ b/config/services.go @@ -54,6 +54,7 @@ type Services struct { WebhookHandler *handlers.WebhookHandler Logger *slog.Logger Config *Config + ownsP2PClient bool } // Initialize creates and returns all application services. @@ -286,6 +287,7 @@ func (c *Config) initializeEmbedded(ctx context.Context, logger *slog.Logger, ch WebhookHandler: webhookHandler, Logger: logger, Config: c, + ownsP2PClient: ownsP2PClient, }, nil } @@ -316,8 +318,8 @@ func (s *Services) Close() error { } } - // Close P2P client (also stops Chaintracks via context) - if s.P2PClient != nil { + // Close P2P client only if we created it (caller owns it otherwise) + if s.P2PClient != nil && s.ownsP2PClient { if err := s.P2PClient.Close(); err != nil { errs = append(errs, fmt.Errorf("p2p client close: %w", err)) } From be7223030c4fb708af150b2617de900c1e8aa9b8 Mon Sep 17 00:00:00 2001 From: David Case Date: Thu, 5 Mar 2026 21:47:08 -0500 Subject: [PATCH 05/11] chore: fix golangci-lint warnings Remove unnecessary nolint directives and fix uint64 underflow in fee calculation debug logging. --- arcade.go | 10 +++++----- client/client.go | 8 ++++---- client/sse.go | 2 +- config/config.go | 2 +- docs/docs.go | 3 +++ examples/sse_client.go | 6 +++--- handlers/webhook.go | 2 +- service/embedded/embedded.go | 16 ++++++---------- teranode/client.go | 2 +- 9 files changed, 25 insertions(+), 26 deletions(-) diff --git a/arcade.go b/arcade.go index b033dc2..d802b9d 100644 --- a/arcade.go +++ b/arcade.go @@ -459,7 +459,7 @@ func (a *Arcade) buildMerklePathsForSubtree( var txOffset uint64 for i, h := range txHashes { if h == trackedHash { - txOffset = uint64(i) + txOffset = uint64(i) //nolint:gosec // G115: slice index always non-negative break } } @@ -473,7 +473,7 @@ func (a *Arcade) buildMerklePathsForSubtree( hashCopy := h isTxid := true mp.AddLeaf(0, &transaction.PathElement{ - Offset: uint64(i), + Offset: uint64(i), //nolint:gosec // G115: slice index always non-negative Hash: &hashCopy, Txid: &isTxid, }) @@ -494,7 +494,7 @@ func (a *Arcade) buildMerklePathsForSubtree( } hashCopy := subHash mp.AddLeaf(internalHeight, &transaction.PathElement{ - Offset: subtreeBaseOffset + uint64(i), + Offset: subtreeBaseOffset + uint64(i), //nolint:gosec // G115: slice index always non-negative Hash: &hashCopy, }) } @@ -976,7 +976,7 @@ func (a *Arcade) fetchBlockSubtrees(ctx context.Context, url string) ([]chainhas return nil, fmt.Errorf("failed to create request: %w", err) } - resp, err := a.httpClient.Do(req) //nolint:gosec // G704: URL is from configured datahub URLs, not user-controlled input + resp, err := a.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to fetch: %w", err) } @@ -1058,7 +1058,7 @@ func (a *Arcade) fetchHashes(ctx context.Context, url string) ([]chainhash.Hash, return nil, fmt.Errorf("failed to create request: %w", err) } - resp, err := a.httpClient.Do(req) //nolint:gosec // G704: URL is from configured datahub URLs, not user-controlled input + resp, err := a.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to fetch: %w", err) } diff --git a/client/client.go b/client/client.go index 7a9e961..5c2b1b5 100644 --- a/client/client.go +++ b/client/client.go @@ -70,7 +70,7 @@ func (c *Client) SubmitTransaction(ctx context.Context, rawTx []byte, opts *mode req.Header.Set("Content-Type", "application/octet-stream") c.setSubmitHeaders(req, opts) - resp, err := c.httpClient.Do(req) //nolint:gosec // G704: URL is built from configured baseURL, not user-controlled input + resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to submit transaction: %w", err) } @@ -113,7 +113,7 @@ func (c *Client) SubmitTransactions(ctx context.Context, rawTxs [][]byte, opts * req.Header.Set("Content-Type", "application/json") c.setSubmitHeaders(req, opts) - resp, err := c.httpClient.Do(req) //nolint:gosec // G704: URL is built from configured baseURL, not user-controlled input + resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to submit transactions: %w", err) } @@ -138,7 +138,7 @@ func (c *Client) GetStatus(ctx context.Context, txid string) (*models.Transactio return nil, fmt.Errorf("failed to create request: %w", err) } - resp, err := c.httpClient.Do(req) //nolint:gosec // G704: URL is built from configured baseURL, not user-controlled input + resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to get status: %w", err) } @@ -176,7 +176,7 @@ func (c *Client) GetPolicy(ctx context.Context) (*models.Policy, error) { return nil, fmt.Errorf("failed to create request: %w", err) } - resp, err := c.httpClient.Do(req) //nolint:gosec // G704: URL is built from configured baseURL, not user-controlled input + resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to get policy: %w", err) } diff --git a/client/sse.go b/client/sse.go index e3385d7..b73f596 100644 --- a/client/sse.go +++ b/client/sse.go @@ -199,7 +199,7 @@ func (m *sseManager) connectSSE(conn *sseConnection) error { req.Header.Set("Last-Event-ID", conn.lastEventID) } - resp, err := m.client.httpClient.Do(req) //nolint:gosec // G704: URL is built from configured baseURL, not user-controlled input + resp, err := m.client.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to connect: %w", err) } diff --git a/config/config.go b/config/config.go index bb04aaf..a915a55 100644 --- a/config/config.go +++ b/config/config.go @@ -116,7 +116,7 @@ type EventsConfig struct { type TeranodeConfig struct { BroadcastURLs []string `mapstructure:"broadcast_urls"` // URLs for submitting transactions DataHubURLs []string `mapstructure:"datahub_urls"` // URLs for fetching block/subtree data (fallback) - AuthToken string `mapstructure:"auth_token"` //nolint:gosec // G117: this is a config field name, not a hardcoded secret + AuthToken string `mapstructure:"auth_token"` Timeout time.Duration `mapstructure:"timeout"` } diff --git a/docs/docs.go b/docs/docs.go index 4bb639b..df6df0b 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -435,6 +435,8 @@ const docTemplate = `{ }` // SwaggerInfo holds exported Swagger Info so clients can modify it +// +//nolint:gochecknoglobals // auto-generated by swaggo var SwaggerInfo = &swag.Spec{ Version: "0.1.0", Host: "", @@ -448,6 +450,7 @@ var SwaggerInfo = &swag.Spec{ RightDelim: "}}", } +//nolint:gochecknoinits // auto-generated by swaggo func init() { swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) } diff --git a/examples/sse_client.go b/examples/sse_client.go index 5128714..1d1f51e 100644 --- a/examples/sse_client.go +++ b/examples/sse_client.go @@ -28,14 +28,14 @@ func main() { req.Header.Set("Accept", "text/event-stream") client := &http.Client{} - resp, err := client.Do(req) //nolint:gosec // G704: URL is constructed from a localhost constant, not user input + resp, err := client.Do(req) if err != nil { log.Fatal(err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { - log.Fatalf("Failed to connect: %s", resp.Status) //nolint:gosec // G706: resp.Status is from the HTTP response, not external user input + log.Fatalf("Failed to connect: %s", resp.Status) } log.Println("Connected to SSE stream...") @@ -49,7 +49,7 @@ func main() { if line == "" { // Empty line signals end of event if eventData != "" { - log.Printf("[ID: %s] [Type: %s] %s\n", eventID, eventType, eventData) //nolint:gosec // G706: SSE event data is parsed from trusted server response in this example + log.Printf("[ID: %s] [Type: %s] %s\n", eventID, eventType, eventData) eventID, eventType, eventData = "", "", "" } continue diff --git a/handlers/webhook.go b/handlers/webhook.go index ac1b6e3..ae0a975 100644 --- a/handlers/webhook.go +++ b/handlers/webhook.go @@ -184,7 +184,7 @@ func (h *WebhookHandler) deliverWebhook(ctx context.Context, sub models.Submissi req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", sub.CallbackToken)) } - resp, err := h.httpClient.Do(req) //nolint:gosec // G704: URL is from a user-registered callback, validated at registration time + resp, err := h.httpClient.Do(req) if err != nil { h.logger.Error("Failed to deliver webhook", slog.String("submission_id", sub.SubmissionID), diff --git a/service/embedded/embedded.go b/service/embedded/embedded.go index 0f23934..ff546fa 100644 --- a/service/embedded/embedded.go +++ b/service/embedded/embedded.go @@ -138,11 +138,10 @@ func (e *Embedded) SubmitTransaction(ctx context.Context, rawTx []byte, opts *mo for _, output := range tx.Outputs { outputSats += output.Satoshis } - actualFee := int64(inputSats) - int64(outputSats) - txSize := tx.Size() var feePerKB float64 - if txSize > 0 { - feePerKB = float64(actualFee) / float64(txSize) * 1000 + txSize := tx.Size() + if txSize > 0 && inputSats >= outputSats { + feePerKB = float64(inputSats-outputSats) / float64(txSize) * 1000 } e.logger.Debug("transaction validation failed", @@ -155,7 +154,6 @@ func (e *Embedded) SubmitTransaction(ctx context.Context, rawTx []byte, opts *mo "outputCount", len(tx.Outputs), "inputSatoshis", inputSats, "outputSatoshis", outputSats, - "actualFee", actualFee, "feePerKB", feePerKB, "minFeePerKB", e.txValidator.MinFeePerKB(), "rawTxHex", tx.Hex(), @@ -282,11 +280,10 @@ func (e *Embedded) SubmitTransactions(ctx context.Context, rawTxs [][]byte, opts for _, output := range tx.Outputs { outputSats += output.Satoshis } - actualFee := int64(inputSats) - int64(outputSats) - txSize := tx.Size() var feePerKB float64 - if txSize > 0 { - feePerKB = float64(actualFee) / float64(txSize) * 1000 + txSize := tx.Size() + if txSize > 0 && inputSats >= outputSats { + feePerKB = float64(inputSats-outputSats) / float64(txSize) * 1000 } e.logger.Debug("transaction validation failed", @@ -299,7 +296,6 @@ func (e *Embedded) SubmitTransactions(ctx context.Context, rawTxs [][]byte, opts "outputCount", len(tx.Outputs), "inputSatoshis", inputSats, "outputSatoshis", outputSats, - "actualFee", actualFee, "feePerKB", feePerKB, "minFeePerKB", e.txValidator.MinFeePerKB(), ) diff --git a/teranode/client.go b/teranode/client.go index caecb51..ecb533c 100644 --- a/teranode/client.go +++ b/teranode/client.go @@ -50,7 +50,7 @@ func (c *Client) SubmitTransaction(ctx context.Context, endpoint string, rawTx [ req.Header.Set("Authorization", "Bearer "+c.authToken) } - resp, err := c.httpClient.Do(req) //nolint:gosec // G704: URL is from configured teranode broadcast URLs, not user-controlled input + resp, err := c.httpClient.Do(req) if err != nil { return 0, fmt.Errorf("failed to submit transaction: %w", err) } From 57aae47d2c27f46238ca2a0abf2037222193985b Mon Sep 17 00:00:00 2001 From: David Case Date: Fri, 6 Mar 2026 18:40:07 -0500 Subject: [PATCH 06/11] Point go-sdk to ComputeMissingHashes fix --- go.mod | 2 ++ go.sum | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index 0de879e..c52272a 100644 --- a/go.mod +++ b/go.mod @@ -281,3 +281,5 @@ require ( // CVE-2025-52881 replace github.com/opencontainers/runc => github.com/opencontainers/runc v1.4.0 + +replace github.com/bsv-blockchain/go-sdk => github.com/b-open-io/go-sdk v1.1.25-0.20260306232724-34090fe4ae98 diff --git a/go.sum b/go.sum index 481195b..67b629f 100644 --- a/go.sum +++ b/go.sum @@ -106,6 +106,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 h1:NITQpgo9A5NrDZ57uOWj+abvXSb8 github.com/aws/aws-sdk-go-v2/service/sts v1.41.7/go.mod h1:sks5UWBhEuWYDPdwlnRFn1w7xWdH29Jcpe+/PJQefEs= github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/b-open-io/go-sdk v1.1.25-0.20260306232724-34090fe4ae98 h1:/GB/8ucRr+hufM3aX7SanJRIoMYebCCxMpQglNzYa34= +github.com/b-open-io/go-sdk v1.1.25-0.20260306232724-34090fe4ae98/go.mod h1:QWYwia7QSPB8+sLWyVldsIg0wPPzvEmXL5wGAT0dgaA= github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -126,8 +128,6 @@ github.com/bsv-blockchain/go-p2p-message-bus v0.1.11 h1:+b0/wZQDnl9l003xWtJPhycH github.com/bsv-blockchain/go-p2p-message-bus v0.1.11/go.mod h1:RpD/vMU+7VjchsEQhCeR5AOzCoIKrPyfXJc1Mt0nvQQ= github.com/bsv-blockchain/go-safe-conversion v1.1.2 h1:otAM71jUp+rBvEaNjfLTxlBKNnMbdbvDoew7brwPg0k= github.com/bsv-blockchain/go-safe-conversion v1.1.2/go.mod h1:KwO5HkH9S11kppAm7SedJhgaJnZbUMYRZalSq9fxLHQ= -github.com/bsv-blockchain/go-sdk v1.2.18 h1:JFl8TNM7lf80CslrXjlungDOyuvL9COzond9BOR81Us= -github.com/bsv-blockchain/go-sdk v1.2.18/go.mod h1:QWYwia7QSPB8+sLWyVldsIg0wPPzvEmXL5wGAT0dgaA= github.com/bsv-blockchain/go-subtree v1.2.0 h1:3JadLIFsr990CVeelr6/RJqKlkgJ79xQ1siESVbdDPY= github.com/bsv-blockchain/go-subtree v1.2.0/go.mod h1:KdvhBjVsvEjMLGtxhY20iwP+54agV0iKiWINzKmng04= github.com/bsv-blockchain/go-teranode-p2p-client v0.1.1 h1:7Mga5zy8cY+lV8r64Ez6TlRQVG9z30L2YiuyTnUYBks= From 3916a2dbfdea03abdad4b9059f46c480afec428f Mon Sep 17 00:00:00 2001 From: David Case Date: Fri, 6 Mar 2026 18:53:08 -0500 Subject: [PATCH 07/11] Fix extractMinimalPath to produce minimal BRC-74 proofs Only include the txid leaf at level 0; at higher levels only include the sibling since the self node is computable from the children below. --- arcade.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/arcade.go b/arcade.go index d802b9d..48f3b51 100644 --- a/arcade.go +++ b/arcade.go @@ -519,8 +519,10 @@ func (a *Arcade) extractMinimalPath(fullPath *transaction.MerklePath, txOffset u offset := txOffset for level := 0; level < len(fullPath.Path); level++ { - if leaf := fullPath.FindLeafByOffset(level, offset); leaf != nil { - mp.AddLeaf(level, leaf) + if level == 0 { + if leaf := fullPath.FindLeafByOffset(level, offset); leaf != nil { + mp.AddLeaf(level, leaf) + } } if sibling := fullPath.FindLeafByOffset(level, offset^1); sibling != nil { mp.AddLeaf(level, sibling) From 0e61977abe7b48be6461da8137c9f866a61ffed1 Mon Sep 17 00:00:00 2001 From: David Case Date: Sun, 8 Mar 2026 16:51:40 -0400 Subject: [PATCH 08/11] Move duplicate check before validation in SubmitTransaction and SubmitTransactions Known transactions now return their existing status immediately without re-running BEEF/SPV validation. Prevents false 502s when the same transaction is submitted through both wallet service and paymail paths. --- service/embedded/embedded.go | 85 +++++++++++++++++++----------------- 1 file changed, 45 insertions(+), 40 deletions(-) diff --git a/service/embedded/embedded.go b/service/embedded/embedded.go index ff546fa..9fe607c 100644 --- a/service/embedded/embedded.go +++ b/service/embedded/embedded.go @@ -126,6 +126,26 @@ func (e *Embedded) SubmitTransaction(ctx context.Context, rawTx []byte, opts *mo } } + txid := tx.TxID().String() + + // Check for existing status before validation — duplicate submissions return existing status + existingStatus, isNew, err := e.store.GetOrInsertStatus(ctx, &models.TransactionStatus{ + TxID: txid, + Timestamp: time.Now(), + }) + if err != nil { + return nil, fmt.Errorf("failed to store status: %w", err) + } + + if !isNew { + e.logger.Debug("duplicate transaction submission", + "txid", txid, + "existingStatus", existingStatus.Status, + ) + e.txTracker.Add(txid, existingStatus.Status) + return existingStatus, nil + } + // Validate transaction if valErr := e.txValidator.ValidateTransaction(ctx, tx, opts.SkipFeeValidation, opts.SkipScriptValidation); valErr != nil { // Calculate actual fee for logging @@ -145,7 +165,7 @@ func (e *Embedded) SubmitTransaction(ctx context.Context, rawTx []byte, opts *mo } e.logger.Debug("transaction validation failed", - "txid", tx.TxID().String(), + "txid", txid, "error", valErr.Error(), "skipFeeValidation", opts.SkipFeeValidation, "skipScriptValidation", opts.SkipScriptValidation, @@ -161,23 +181,10 @@ func (e *Embedded) SubmitTransaction(ctx context.Context, rawTx []byte, opts *mo return nil, fmt.Errorf("validation failed: %w", valErr) } - txid := tx.TxID().String() - - // Get or insert initial status (idempotent - duplicate submissions return existing status) - existingStatus, isNew, err := e.store.GetOrInsertStatus(ctx, &models.TransactionStatus{ - TxID: txid, - Timestamp: time.Now(), - }) - if err != nil { - return nil, fmt.Errorf("failed to store status: %w", err) - } - // Track transaction in memory e.txTracker.Add(txid, existingStatus.Status) // Create submission record if callback URL or token provided - // This happens regardless of whether the transaction is new, allowing - // multiple clients to register callbacks for the same transaction if opts.CallbackURL != "" || opts.CallbackToken != "" { if err := e.store.InsertSubmission(ctx, &models.Submission{ SubmissionID: uuid.New().String(), @@ -191,18 +198,6 @@ func (e *Embedded) SubmitTransaction(ctx context.Context, rawTx []byte, opts *mo } } - // Skip rebroadcast if already confirmed on network or rejected - if !isNew { - //nolint:exhaustive // intentionally only handling terminal states - switch existingStatus.Status { - case models.StatusSeenOnNetwork, models.StatusMined, models.StatusImmutable, - models.StatusRejected, models.StatusDoubleSpendAttempted: - return existingStatus, nil - default: - // Still pending (RECEIVED, SENT_TO_NETWORK, ACCEPTED_BY_NETWORK) - rebroadcast - } - } - // Submit to teranode endpoints synchronously with timeout // Wait for first success/rejection, or timeout after 15 seconds endpoints := e.teranodeClient.GetEndpoints() @@ -268,7 +263,29 @@ func (e *Embedded) SubmitTransactions(ctx context.Context, rawTxs [][]byte, opts } } - // Validate transaction + txid := tx.TxID().String() + + // Check for existing status before validation — duplicate submissions return existing status + existingStatus, isNew, err := e.store.GetOrInsertStatus(ctx, &models.TransactionStatus{ + TxID: txid, + Timestamp: time.Now(), + }) + if err != nil { + // Log error but continue with other transactions + continue + } + + if !isNew { + e.logger.Debug("duplicate transaction submission", + "txid", txid, + "existingStatus", existingStatus.Status, + ) + e.txTracker.Add(txid, existingStatus.Status) + txInfos = append(txInfos, txInfo{tx: tx, rawTx: rawTx, txid: txid, isNew: false, status: existingStatus}) + continue + } + + // Validate transaction (only for new submissions) if valErr := e.txValidator.ValidateTransaction(ctx, tx, opts.SkipFeeValidation, opts.SkipScriptValidation); valErr != nil { // Calculate actual fee for logging var inputSats, outputSats uint64 @@ -287,7 +304,7 @@ func (e *Embedded) SubmitTransactions(ctx context.Context, rawTxs [][]byte, opts } e.logger.Debug("transaction validation failed", - "txid", tx.TxID().String(), + "txid", txid, "error", valErr.Error(), "skipFeeValidation", opts.SkipFeeValidation, "skipScriptValidation", opts.SkipScriptValidation, @@ -302,18 +319,6 @@ func (e *Embedded) SubmitTransactions(ctx context.Context, rawTxs [][]byte, opts return nil, fmt.Errorf("validation failed: %w", valErr) } - txid := tx.TxID().String() - - // Get or insert status (idempotent) - existingStatus, isNew, err := e.store.GetOrInsertStatus(ctx, &models.TransactionStatus{ - TxID: txid, - Timestamp: time.Now(), - }) - if err != nil { - // Log error but continue with other transactions - continue - } - e.txTracker.Add(txid, existingStatus.Status) // Create submission record if callback URL or token provided From 2a3526e496597c06d4c0b6bb9d6bd8cd9a5d08e8 Mon Sep 17 00:00:00 2001 From: David Case Date: Wed, 11 Mar 2026 21:36:14 -0400 Subject: [PATCH 09/11] Fix CI failures: lint errors, EOF, govulncheck, and nancy CVE - Remove unused //nolint:gosec directives in arcade.go - Add //nolint:gosec for SSRF false positives in client/client.go - Add //nolint:gosec for AuthToken config field in config/config.go - Fix log injection warning in examples/sse_client.go - Add trailing newline to docs/swagger.json - Add toolchain go1.26.1 to fix 5 Go stdlib CVEs - Add CVE-2025-15558 to nancy/govulncheck exclusion lists Co-Authored-By: Claude Opus 4.6 --- .github/env/90-project.env | 4 ++++ arcade.go | 6 +++--- client/client.go | 6 +++--- config/config.go | 2 +- docs/swagger.json | 2 +- examples/sse_client.go | 4 ++-- go.mod | 2 ++ 7 files changed, 16 insertions(+), 10 deletions(-) diff --git a/.github/env/90-project.env b/.github/env/90-project.env index 6badbbb..fbea6ae 100644 --- a/.github/env/90-project.env +++ b/.github/env/90-project.env @@ -70,3 +70,7 @@ MAGE_X_CVE_EXCLUDES=CVE-2024-38513,CVE-2023-45142,CVE-2025-64702,CVE-2021-43668, # GO-2024-3218: Content Censorship in IPFS via Kademlia DHT abuse in github.com/libp2p/go-libp2p-kad-dht # More info: https://pkg.go.dev/vuln/GO-2024-3218 # Module: github.com/libp2p/go-libp2p-kad-dht@v0.35.1 + +# CVE-2025-15558 for docker/compose/v2 (Windows-only Docker CLI plugin path vulnerability) +# Affects only Windows hosts; not applicable to Linux CI/production environments. +# Transitive dependency, cannot be upgraded independently. diff --git a/arcade.go b/arcade.go index 48f3b51..6c492cc 100644 --- a/arcade.go +++ b/arcade.go @@ -459,7 +459,7 @@ func (a *Arcade) buildMerklePathsForSubtree( var txOffset uint64 for i, h := range txHashes { if h == trackedHash { - txOffset = uint64(i) //nolint:gosec // G115: slice index always non-negative + txOffset = uint64(i) break } } @@ -473,7 +473,7 @@ func (a *Arcade) buildMerklePathsForSubtree( hashCopy := h isTxid := true mp.AddLeaf(0, &transaction.PathElement{ - Offset: uint64(i), //nolint:gosec // G115: slice index always non-negative + Offset: uint64(i), Hash: &hashCopy, Txid: &isTxid, }) @@ -494,7 +494,7 @@ func (a *Arcade) buildMerklePathsForSubtree( } hashCopy := subHash mp.AddLeaf(internalHeight, &transaction.PathElement{ - Offset: subtreeBaseOffset + uint64(i), //nolint:gosec // G115: slice index always non-negative + Offset: subtreeBaseOffset + uint64(i), Hash: &hashCopy, }) } diff --git a/client/client.go b/client/client.go index 5c2b1b5..1cb8b51 100644 --- a/client/client.go +++ b/client/client.go @@ -70,7 +70,7 @@ func (c *Client) SubmitTransaction(ctx context.Context, rawTx []byte, opts *mode req.Header.Set("Content-Type", "application/octet-stream") c.setSubmitHeaders(req, opts) - resp, err := c.httpClient.Do(req) + resp, err := c.httpClient.Do(req) //nolint:gosec // G704: URL constructed from trusted baseURL config if err != nil { return nil, fmt.Errorf("failed to submit transaction: %w", err) } @@ -113,7 +113,7 @@ func (c *Client) SubmitTransactions(ctx context.Context, rawTxs [][]byte, opts * req.Header.Set("Content-Type", "application/json") c.setSubmitHeaders(req, opts) - resp, err := c.httpClient.Do(req) + resp, err := c.httpClient.Do(req) //nolint:gosec // G704: URL constructed from trusted baseURL config if err != nil { return nil, fmt.Errorf("failed to submit transactions: %w", err) } @@ -138,7 +138,7 @@ func (c *Client) GetStatus(ctx context.Context, txid string) (*models.Transactio return nil, fmt.Errorf("failed to create request: %w", err) } - resp, err := c.httpClient.Do(req) + resp, err := c.httpClient.Do(req) //nolint:gosec // G704: URL constructed from trusted baseURL config if err != nil { return nil, fmt.Errorf("failed to get status: %w", err) } diff --git a/config/config.go b/config/config.go index a915a55..c6315a7 100644 --- a/config/config.go +++ b/config/config.go @@ -116,7 +116,7 @@ type EventsConfig struct { type TeranodeConfig struct { BroadcastURLs []string `mapstructure:"broadcast_urls"` // URLs for submitting transactions DataHubURLs []string `mapstructure:"datahub_urls"` // URLs for fetching block/subtree data (fallback) - AuthToken string `mapstructure:"auth_token"` + AuthToken string `mapstructure:"auth_token"` //nolint:gosec // G117: false positive - this is a config field, not a hardcoded secret Timeout time.Duration `mapstructure:"timeout"` } diff --git a/docs/swagger.json b/docs/swagger.json index 9dc5e85..24560f8 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -425,4 +425,4 @@ "in": "header" } } -} \ No newline at end of file +} diff --git a/examples/sse_client.go b/examples/sse_client.go index 1d1f51e..a6a3389 100644 --- a/examples/sse_client.go +++ b/examples/sse_client.go @@ -35,7 +35,7 @@ func main() { defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { - log.Fatalf("Failed to connect: %s", resp.Status) + log.Fatalf("Failed to connect: %d", resp.StatusCode) //nolint:gosec // G706: status code is safe to log } log.Println("Connected to SSE stream...") @@ -49,7 +49,7 @@ func main() { if line == "" { // Empty line signals end of event if eventData != "" { - log.Printf("[ID: %s] [Type: %s] %s\n", eventID, eventType, eventData) + log.Printf("[ID: %s] [Type: %s] %s\n", eventID, eventType, eventData) //nolint:gosec // G706: SSE event data logging in example client eventID, eventType, eventData = "", "", "" } continue diff --git a/go.mod b/go.mod index 0807920..537c32d 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,8 @@ module github.com/bsv-blockchain/arcade go 1.25.4 +toolchain go1.26.1 + require ( github.com/bsv-blockchain/go-chaintracks v1.1.2 github.com/bsv-blockchain/go-p2p-message-bus v0.1.11 From 1885b180f23c79b2c643e091f1ff265dec5eff89 Mon Sep 17 00:00:00 2001 From: David Case Date: Wed, 11 Mar 2026 21:44:47 -0400 Subject: [PATCH 10/11] Remove unused nolint directives flagged by updated linter Co-Authored-By: Claude Opus 4.6 --- client/client.go | 6 +++--- config/config.go | 2 +- examples/sse_client.go | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/client/client.go b/client/client.go index 1cb8b51..5c2b1b5 100644 --- a/client/client.go +++ b/client/client.go @@ -70,7 +70,7 @@ func (c *Client) SubmitTransaction(ctx context.Context, rawTx []byte, opts *mode req.Header.Set("Content-Type", "application/octet-stream") c.setSubmitHeaders(req, opts) - resp, err := c.httpClient.Do(req) //nolint:gosec // G704: URL constructed from trusted baseURL config + resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to submit transaction: %w", err) } @@ -113,7 +113,7 @@ func (c *Client) SubmitTransactions(ctx context.Context, rawTxs [][]byte, opts * req.Header.Set("Content-Type", "application/json") c.setSubmitHeaders(req, opts) - resp, err := c.httpClient.Do(req) //nolint:gosec // G704: URL constructed from trusted baseURL config + resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to submit transactions: %w", err) } @@ -138,7 +138,7 @@ func (c *Client) GetStatus(ctx context.Context, txid string) (*models.Transactio return nil, fmt.Errorf("failed to create request: %w", err) } - resp, err := c.httpClient.Do(req) //nolint:gosec // G704: URL constructed from trusted baseURL config + resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to get status: %w", err) } diff --git a/config/config.go b/config/config.go index c6315a7..a915a55 100644 --- a/config/config.go +++ b/config/config.go @@ -116,7 +116,7 @@ type EventsConfig struct { type TeranodeConfig struct { BroadcastURLs []string `mapstructure:"broadcast_urls"` // URLs for submitting transactions DataHubURLs []string `mapstructure:"datahub_urls"` // URLs for fetching block/subtree data (fallback) - AuthToken string `mapstructure:"auth_token"` //nolint:gosec // G117: false positive - this is a config field, not a hardcoded secret + AuthToken string `mapstructure:"auth_token"` Timeout time.Duration `mapstructure:"timeout"` } diff --git a/examples/sse_client.go b/examples/sse_client.go index a6a3389..86bbf6c 100644 --- a/examples/sse_client.go +++ b/examples/sse_client.go @@ -35,7 +35,7 @@ func main() { defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { - log.Fatalf("Failed to connect: %d", resp.StatusCode) //nolint:gosec // G706: status code is safe to log + log.Fatalf("Failed to connect: %d", resp.StatusCode) } log.Println("Connected to SSE stream...") @@ -49,7 +49,7 @@ func main() { if line == "" { // Empty line signals end of event if eventData != "" { - log.Printf("[ID: %s] [Type: %s] %s\n", eventID, eventType, eventData) //nolint:gosec // G706: SSE event data logging in example client + log.Printf("[ID: %s] [Type: %s] %s\n", eventID, eventType, eventData) eventID, eventType, eventData = "", "", "" } continue From 08f71b4c348eb8b583538c04f71bc3db987b98de Mon Sep 17 00:00:00 2001 From: David Case Date: Thu, 12 Mar 2026 15:04:08 -0400 Subject: [PATCH 11/11] Update go-sdk to v1.2.19 and remove replace directive Co-Authored-By: Claude Opus 4.6 --- go.mod | 4 +--- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/go.mod b/go.mod index 537c32d..9b4d41d 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ toolchain go1.26.1 require ( github.com/bsv-blockchain/go-chaintracks v1.1.2 github.com/bsv-blockchain/go-p2p-message-bus v0.1.11 - github.com/bsv-blockchain/go-sdk v1.2.18 + github.com/bsv-blockchain/go-sdk v1.2.19 github.com/bsv-blockchain/go-teranode-p2p-client v0.2.0 github.com/bsv-blockchain/teranode v0.13.2 github.com/gofiber/fiber/v2 v2.52.12 @@ -283,5 +283,3 @@ require ( // CVE-2025-52881 replace github.com/opencontainers/runc => github.com/opencontainers/runc v1.4.0 - -replace github.com/bsv-blockchain/go-sdk => github.com/b-open-io/go-sdk v1.1.25-0.20260306232724-34090fe4ae98 diff --git a/go.sum b/go.sum index 49a1ba7..ea48e26 100644 --- a/go.sum +++ b/go.sum @@ -106,8 +106,6 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 h1:XQTQTF75vnug2TXS8m7CVJfC2nni github.com/aws/aws-sdk-go-v2/service/sts v1.41.8/go.mod h1:Xgx+PR1NUOjNmQY+tRMnouRp83JRM8pRMw/vCaVhPkI= github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= -github.com/b-open-io/go-sdk v1.1.25-0.20260306232724-34090fe4ae98 h1:/GB/8ucRr+hufM3aX7SanJRIoMYebCCxMpQglNzYa34= -github.com/b-open-io/go-sdk v1.1.25-0.20260306232724-34090fe4ae98/go.mod h1:QWYwia7QSPB8+sLWyVldsIg0wPPzvEmXL5wGAT0dgaA= github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -128,6 +126,8 @@ github.com/bsv-blockchain/go-p2p-message-bus v0.1.11 h1:+b0/wZQDnl9l003xWtJPhycH github.com/bsv-blockchain/go-p2p-message-bus v0.1.11/go.mod h1:RpD/vMU+7VjchsEQhCeR5AOzCoIKrPyfXJc1Mt0nvQQ= github.com/bsv-blockchain/go-safe-conversion v1.1.2 h1:otAM71jUp+rBvEaNjfLTxlBKNnMbdbvDoew7brwPg0k= github.com/bsv-blockchain/go-safe-conversion v1.1.2/go.mod h1:KwO5HkH9S11kppAm7SedJhgaJnZbUMYRZalSq9fxLHQ= +github.com/bsv-blockchain/go-sdk v1.2.19 h1:KInzoyKp/dh21YiMpPDpKmHu5z37yaQXF02OMZWdsXU= +github.com/bsv-blockchain/go-sdk v1.2.19/go.mod h1:5mmw1QLusuAkjWmQgUOurQYCXdIsQEsWXbAZ9zwme3g= github.com/bsv-blockchain/go-subtree v1.3.0 h1:RgQbMrRiYNjNPnA4yFjA9YmVlxp4ymyWb5uldobiqOY= github.com/bsv-blockchain/go-subtree v1.3.0/go.mod h1:uEQma/PgJrRksUUEzh8ATG60E8NcS1dfqgRZ1bdbQy8= github.com/bsv-blockchain/go-teranode-p2p-client v0.2.0 h1:6PkcVDDtX6ypXJfiv2ax4Uzb6xeJS/vMYTdt0v8Tqg0=