From cde6e63da987673e151180305c4b93244f18e189 Mon Sep 17 00:00:00 2001 From: Simone Orsi <241460653+simone-stacks@users.noreply.github.com> Date: Thu, 4 Dec 2025 04:12:49 -0600 Subject: [PATCH 01/15] correct some OpenAPI schema validations and response types --- .../schemas/clarity-data.schema.yaml | 4 +- .../components/schemas/pox-info.schema.yaml | 1 - .../transaction-submission-error.schema.yaml | 1 - docs/rpc/openapi.yaml | 71 +++++++++++++++---- 4 files changed, 59 insertions(+), 18 deletions(-) diff --git a/docs/rpc/components/schemas/clarity-data.schema.yaml b/docs/rpc/components/schemas/clarity-data.schema.yaml index 53082adfb04..9022074fb56 100644 --- a/docs/rpc/components/schemas/clarity-data.schema.yaml +++ b/docs/rpc/components/schemas/clarity-data.schema.yaml @@ -9,5 +9,5 @@ properties: pattern: "^0x[0-9a-f]+$" proof: type: string - description: Hex-encoded 0x prefixed string of the Merkle proof for the data - pattern: "^0x[0-9a-f]+$" + description: Hex-encoded 0x prefixed string of the Merkle proof for the data. Empty string if proof not requested. + pattern: "^(0x[0-9a-f]+)?$" diff --git a/docs/rpc/components/schemas/pox-info.schema.yaml b/docs/rpc/components/schemas/pox-info.schema.yaml index ea9abc2cb85..70277db5551 100644 --- a/docs/rpc/components/schemas/pox-info.schema.yaml +++ b/docs/rpc/components/schemas/pox-info.schema.yaml @@ -125,7 +125,6 @@ properties: Any eligible stacks must be stacked before this block. blocks_until_prepare_phase: type: integer - minimum: 0 description: The number of burn blocks until the prepare phase for this cycle starts. If the prepare phase for this cycle already started, this value diff --git a/docs/rpc/components/schemas/transaction-submission-error.schema.yaml b/docs/rpc/components/schemas/transaction-submission-error.schema.yaml index f78efc464c6..261bd3c54af 100644 --- a/docs/rpc/components/schemas/transaction-submission-error.schema.yaml +++ b/docs/rpc/components/schemas/transaction-submission-error.schema.yaml @@ -3,7 +3,6 @@ type: object required: - error - reason - - reason_data - txid properties: error: diff --git a/docs/rpc/openapi.yaml b/docs/rpc/openapi.yaml index d70875a00ec..2388d0e1713 100644 --- a/docs/rpc/openapi.yaml +++ b/docs/rpc/openapi.yaml @@ -182,11 +182,13 @@ paths: - Size limits operationId: broadcastTransaction requestBody: + required: true content: application/octet-stream: schema: type: string format: binary + minLength: 1 example: binary format of 00000000010400bed38c2aadffa348931bcb542880ff79d607afec000000000000000000000000000000c800012b0b1fff6cccd0974966dcd665835838f0985be508e1322e09fb3d751eca132c492bda720f9ef1768d14fdabed6127560ba52d5e3ac470dcb60b784e97dc88c9030200000000000516df0ba3e79792be7be5e50a370289accfc8c9e032000000000000303974657374206d656d6f00000000000000000000000000000000000000000000000000 application/json: schema: @@ -221,6 +223,10 @@ paths: $ref: "#/components/schemas/TransactionSubmissionError" example: $ref: "./components/examples/transaction-submission-error.example.json" + text/plain: + schema: + type: string + example: "Failed to decode transaction" "500": $ref: "#/components/responses/InternalServerError" @@ -293,6 +299,10 @@ paths: application/json: schema: type: string + minLength: 4 + pattern: "^0x[0-9a-fA-F]+$" + description: Hex-encoded Clarity value (e.g. "0x0100000000000000000000000000000001") + example: "0x0100000000000000000000000000000001" responses: "200": description: Success @@ -572,7 +582,8 @@ paths: schema: $ref: "#/components/schemas/FeeTransactionRequest" example: - $ref: "./components/examples/fee-transaction-request.example.json" + estimated_len: 350 + transaction_payload: "021af942874ce525e87f21bbe8c121b12fac831d02f4086765742d696e666f0b7570646174652d696e666f00000000" responses: "200": description: Estimated fees for the transaction @@ -588,6 +599,10 @@ paths: application/json: schema: $ref: "#/components/schemas/FeeTransactionError" + text/plain: + schema: + type: string + example: "Failed to decode: Failed to parse JSON body" "500": $ref: "#/components/responses/InternalServerError" @@ -651,6 +666,12 @@ paths: $ref: "#/components/schemas/PoxInfo" example: $ref: "./components/examples/pox-info.example.json" + "400": + $ref: "#/components/responses/BadRequest" + "404": + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalServerError" parameters: - $ref: ./components/parameters/tip.yaml @@ -843,12 +864,14 @@ paths: properties: block: type: string + minLength: 1 description: Hex-encoded block data chain_id: type: integer description: Chain ID for the block example: - $ref: "./components/examples/post-block-proposal-request.example.json" + block: "00000000000000001f00000000000927c08fb5ae5bf80e39e4168f6a3fddb0407a069d21ee68465e6856393254d2a66194f44bb01070666d5effcfb2436e209a75878fe80a04b4258a8cd34ab97c38a8dde331a2a509dd7e4b90590726866172cc138c18e80567737667f55d3f9817ce4714c91d1adfd36101141829dc0b5ea0c4944668c0005ddb6f9e2718f60014f21932a42a36ffaf58e88e77b217b2af366c15dd59e6b136ca773729832dcfc5875ec0830d04012dd5a4fa77a196646ea2b356289116fd02558c034b62d63f8a65bdd20d7ffc3fec6c266cd974be776a9e92759b90f288dcc2525b6b6bd5622c5f02e0922440e9ad1095c19b4467fd94566caa9755669d8e0000000180800000000400f64081ae6209dce9245753a4f764d6f168aae1af00000000000000000000000000000064000041dbcc7391991c1a18371eb49b879240247a3ec7f281328f53976c1218ffd65421dbb101e59370e2c972b29f48dc674b2de5e1b65acbd41d5d2689124d42c16c01010000000000051a346048df62be3a52bb6236e11394e8600229e27b000000000000271000000000000000000000000000000000000000000000000000000000000000000000" + chain_id: 2147483648 responses: "202": description: | @@ -986,10 +1009,12 @@ paths: parameters: - name: block_height in: path - description: The block's height + description: The block's height (max 4294967295) required: true schema: type: integer + minimum: 0 + maximum: 4294967295 - $ref: ./components/parameters/tip.yaml responses: "200": @@ -1063,6 +1088,12 @@ paths: schema: type: string format: binary + "400": + $ref: "#/components/responses/BadRequest" + "404": + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalServerError" /v3/tenures/blocks/{consensus_hash}: get: @@ -1149,6 +1180,8 @@ paths: required: true schema: type: integer + minimum: 0 + maximum: 4294967295 responses: "200": description: List of Stacks blocks in the tenure @@ -1299,10 +1332,11 @@ paths: - name: height in: path required: true - description: Burn block height (integer) + description: Burn block height (integer, max 4294967295) schema: type: integer minimum: 0 + maximum: 4294967295 responses: "200": description: Sortition information for the burn block height @@ -1353,6 +1387,12 @@ paths: $ref: "#/components/schemas/SignerBlocksSigned" example: blocks_signed: 7 + "400": + $ref: "#/components/responses/BadRequest" + "404": + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalServerError" /v3/transaction/{txid}: get: @@ -1474,12 +1514,12 @@ paths: - name: pages_indexes in: query required: true - description: Comma-separated list of page indexes to query + description: Comma-separated list of page indexes to query (max 8 pages, each index max 10 digits) schema: type: string example: "1,2,3" - pattern: "^[0-9]+(,[0-9]+){0,7}$" - description: max 8 pages per request + pattern: "^[0-9]{1,10}(,[0-9]{1,10}){0,7}$" + description: max 8 pages per request, each page index limited to 10 digits responses: "200": description: Attachment inventory bitfield @@ -1626,6 +1666,7 @@ paths: schema: type: string format: binary + minLength: 1 responses: "200": description: Index-block hash of the accepted microblock @@ -1670,6 +1711,8 @@ paths: schema: type: string format: binary + "400": + $ref: "#/components/responses/BadRequest" "404": $ref: "#/components/responses/NotFound" "500": @@ -1773,7 +1816,10 @@ paths: schema: $ref: "#/components/schemas/StackerDbChunkData" example: - $ref: "./components/examples/stackerdb-chunk-data-request.example.json" + slot_id: 1 + slot_version: 2 + sig: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef01" + data: "deadbeefcafebabe" responses: "200": description: Chunk submission result (both success and failure cases) @@ -2083,6 +2129,7 @@ paths: schema: type: string format: binary + minLength: 1 description: Binary-encoded Stacks block responses: "200": @@ -2125,15 +2172,10 @@ paths: schema: type: string format: binary + minLength: 1 description: | Binary SIP-003 encoding of `MemPoolSyncData` (`BloomFilter` or `TxTags` variants). - properties: - transactions: - type: array - items: - type: string - description: Transaction IDs responses: "200": description: Stream of missing transactions @@ -2185,6 +2227,7 @@ paths: schema: type: string format: binary + minLength: 1 description: Binary SIP-003 encoding of a `NakamotoBlock` responses: "200": From 1bc0a971868f9601ddd3dd579583a11119615bdb Mon Sep 17 00:00:00 2001 From: Simone Orsi <241460653+simone-stacks@users.noreply.github.com> Date: Thu, 11 Dec 2025 04:31:24 -0600 Subject: [PATCH 02/15] add principal/contract-address schemas and fix type constraints --- .../parameters/contract-address.yaml | 9 ++- docs/rpc/components/parameters/principal.yaml | 12 ++- .../parameters/standard-principal.yaml | 15 ++++ docs/rpc/components/parameters/tip.yaml | 3 +- .../schemas/clarity-name.schema.yaml | 7 +- .../read-only-function-args.schema.yaml | 23 +++++- docs/rpc/openapi.yaml | 74 ++++++++++++++----- 7 files changed, 114 insertions(+), 29 deletions(-) create mode 100644 docs/rpc/components/parameters/standard-principal.yaml diff --git a/docs/rpc/components/parameters/contract-address.yaml b/docs/rpc/components/parameters/contract-address.yaml index abe6462fe8e..b8bae7489ab 100644 --- a/docs/rpc/components/parameters/contract-address.yaml +++ b/docs/rpc/components/parameters/contract-address.yaml @@ -3,10 +3,13 @@ in: path required: true description: | Standard Stacks address (e.g. `SP31DA6FTSJX2WGTZ69SFY11BH51NZMB0ZW97B5P0`). - Must be 28-41 characters long using Stacks base58check format. + Must start with SP (mainnet) or ST (testnet), followed by 26-39 c32check characters. + Note: The API validates the cryptographic checksum internally; invalid addresses return appropriate errors. schema: type: string - pattern: "^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{28,41}$" + pattern: "^S[PT][0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26,39}$" minLength: 28 maxLength: 41 - example: SP31DA6FTSJX2WGTZ69SFY11BH51NZMB0ZW97B5P0 + examples: + - SP31DA6FTSJX2WGTZ69SFY11BH51NZMB0ZW97B5P0 + - ST000000000000000000002AMW42H diff --git a/docs/rpc/components/parameters/principal.yaml b/docs/rpc/components/parameters/principal.yaml index fa0e4d75928..3cb818172bb 100644 --- a/docs/rpc/components/parameters/principal.yaml +++ b/docs/rpc/components/parameters/principal.yaml @@ -2,12 +2,16 @@ name: principal in: path required: true description: | - Stacks address (28-41 characters) or a Contract identifier in format `{address}.{contract_name}` + Stacks address or a Contract identifier in format `{address}.{contract_name}` (e.g. `SP31DA6FTSJX2WGTZ69SFY11BH51NZMB0ZW97B5P0.get-info`). - Contract names have a maximum length of 40 characters for new contracts. Legacy contracts may have names up to 128 characters. + Address must start with SP (mainnet) or ST (testnet) followed by c32check characters. + Note: The API validates the cryptographic checksum internally; invalid addresses return appropriate errors. schema: type: string - pattern: "^([0123456789ABCDEFGHJKMNPQRSTVWXYZ]{28,41})|([0123456789ABCDEFGHJKMNPQRSTVWXYZ]{28,41}\\.[a-zA-Z]([a-zA-Z0-9]|[-_]){0,127})$" + pattern: "^S[PT][0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26,39}(\\.[a-zA-Z]([a-zA-Z0-9]|[-_]){0,127})?$" minLength: 28 maxLength: 170 - example: SP31DA6FTSJX2WGTZ69SFY11BH51NZMB0ZW97B5P0.get-info + examples: + - SP31DA6FTSJX2WGTZ69SFY11BH51NZMB0ZW97B5P0 + - SP31DA6FTSJX2WGTZ69SFY11BH51NZMB0ZW97B5P0.get-info + - ST000000000000000000002AMW42H diff --git a/docs/rpc/components/parameters/standard-principal.yaml b/docs/rpc/components/parameters/standard-principal.yaml new file mode 100644 index 00000000000..3c36b9a408a --- /dev/null +++ b/docs/rpc/components/parameters/standard-principal.yaml @@ -0,0 +1,15 @@ +name: principal +in: path +required: true +description: | + Stacks address (standard principal, not contract principal). + Must start with SP (mainnet) or ST (testnet), followed by 26-39 c32check characters. + Note: The API validates the cryptographic checksum internally; invalid addresses return appropriate errors. +schema: + type: string + pattern: "^S[PT][0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26,39}$" + minLength: 28 + maxLength: 41 + examples: + - SP31DA6FTSJX2WGTZ69SFY11BH51NZMB0ZW97B5P0 + - ST000000000000000000002AMW42H diff --git a/docs/rpc/components/parameters/tip.yaml b/docs/rpc/components/parameters/tip.yaml index d6a53a06133..879ad569b89 100644 --- a/docs/rpc/components/parameters/tip.yaml +++ b/docs/rpc/components/parameters/tip.yaml @@ -2,11 +2,10 @@ name: tip in: query schema: type: string - pattern: "^(latest|[0-9a-f]{64})?$" - maxLength: 64 example: latest description: | Stacks chain tip to query from. Options: - (empty/omitted): Use latest anchored tip (canonical confirmed state) - `latest`: Use latest known tip including unconfirmed microblocks - `{block_id}`: Use specific block ID (64 hex characters) + Note: Invalid or unrecognized tip values are ignored and the default tip is used. diff --git a/docs/rpc/components/schemas/clarity-name.schema.yaml b/docs/rpc/components/schemas/clarity-name.schema.yaml index e4dc469e40f..9a0d644376f 100644 --- a/docs/rpc/components/schemas/clarity-name.schema.yaml +++ b/docs/rpc/components/schemas/clarity-name.schema.yaml @@ -1,4 +1,9 @@ type: string -pattern: "^([a-zA-Z0-9_]|[-!?+<>=/*]){1,128}$" +description: | + A valid Clarity name. Must either: + - Start with a letter and contain only letters, numbers, and [-_!?+<>=/*] + - Be exactly one of the special characters: - + = * / + - Be a comparison operator: < > <= >= +pattern: "^[a-zA-Z]([a-zA-Z0-9]|[-_!?+<>=/*])*$|^[-+=/*]$|^[<>]=?$" minLength: 1 maxLength: 128 diff --git a/docs/rpc/components/schemas/read-only-function-args.schema.yaml b/docs/rpc/components/schemas/read-only-function-args.schema.yaml index d72cdea84b2..9233863509c 100644 --- a/docs/rpc/components/schemas/read-only-function-args.schema.yaml +++ b/docs/rpc/components/schemas/read-only-function-args.schema.yaml @@ -1,17 +1,36 @@ description: Describes representation of a Type-0 Stacks 2.0 transaction. https://github.com/stacksgov/sips/blob/main/sips/sip-005/sip-005-blocks-and-transactions.md#type-0-transferring-an-asset type: object +additionalProperties: false required: - sender - arguments properties: sender: type: string - description: The simulated tx-sender + description: | + The simulated tx-sender (standard principal or contract principal). + Must start with SP (mainnet) or ST (testnet). + pattern: "^S[PT][0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26,39}(\\.[a-zA-Z]([a-zA-Z0-9]|[-_]){0,127})?$" + minLength: 28 + maxLength: 170 + examples: + - SP31DA6FTSJX2WGTZ69SFY11BH51NZMB0ZW97B5P0 + - ST000000000000000000002AMW42H sponsor: type: string - description: The simulated sponsor address + description: | + The simulated sponsor address. + Must start with SP (mainnet) or ST (testnet). + pattern: "^S[PT][0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26,39}(\\.[a-zA-Z]([a-zA-Z0-9]|[-_]){0,127})?$" + minLength: 28 + maxLength: 170 + examples: + - SP31DA6FTSJX2WGTZ69SFY11BH51NZMB0ZW97B5P0 + - ST000000000000000000002AMW42H arguments: type: array description: An array of hex serialized Clarity values items: type: string + pattern: "^(0x)?[0-9a-fA-F]*$" + description: Hex-encoded Clarity value (optionally prefixed with 0x) diff --git a/docs/rpc/openapi.yaml b/docs/rpc/openapi.yaml index 2388d0e1713..6d92d75fc97 100644 --- a/docs/rpc/openapi.yaml +++ b/docs/rpc/openapi.yaml @@ -74,6 +74,13 @@ components: schema: type: string example: "Internal Server Error" + MethodNotAllowed: + description: Method Not Allowed + content: + text/plain: + schema: + type: string + example: "Method Not Allowed. Allowed: GET" Timeout: description: Timeout content: @@ -693,12 +700,13 @@ paths: required: true description: | Stacks address of the trait-defining contract. + Must start with SP (mainnet) or ST (testnet). schema: type: string - pattern: "^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{28,41}$" + pattern: "^S[PT][0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26,39}$" minLength: 28 maxLength: 41 - example: "SP2Z1K16238380NBP4T38A4G10A90Q03JJ2C2003" + example: "SP31DA6FTSJX2WGTZ69SFY11BH51NZMB0ZW97B5P0" - name: trait_contract_name in: path required: true @@ -800,9 +808,15 @@ paths: - name: clarity_metadata_key in: path required: true - description: Metadata key + description: | + Metadata key. Must be either: + - "analysis" for contract analysis data + - "vm-metadata::N::TYPE" where N is the epoch (1-2 digits) and TYPE is one of: + contract, contract-size, contract-src, contract-data-size, or a valid Clarity name schema: type: string + pattern: "^(analysis)|(vm-metadata::\\d{1,2}::(contract|contract-size|contract-src|contract-data-size|[a-zA-Z]([a-zA-Z0-9]|[-_!?+<>=/*])*))$" + example: analysis - $ref: ./components/parameters/tip.yaml /v2/constant_val/{contract_address}/{contract_name}/{constant_name}: @@ -864,10 +878,12 @@ paths: properties: block: type: string - minLength: 1 - description: Hex-encoded block data + minLength: 400 + pattern: "^[0-9a-fA-F]+$" + description: Hex-encoded block data (must be valid SIP-003 serialized block, typically 200+ bytes) chain_id: type: integer + minimum: 0 description: Chain ID for the block example: block: "00000000000000001f00000000000927c08fb5ae5bf80e39e4168f6a3fddb0407a069d21ee68465e6856393254d2a66194f44bb01070666d5effcfb2436e209a75878fe80a04b4258a8cd34ab97c38a8dde331a2a509dd7e4b90590726866172cc138c18e80567737667f55d3f9817ce4714c91d1adfd36101141829dc0b5ea0c4944668c0005ddb6f9e2718f60014f21932a42a36ffaf58e88e77b217b2af366c15dd59e6b136ca773729832dcfc5875ec0830d04012dd5a4fa77a196646ea2b356289116fd02558c034b62d63f8a65bdd20d7ffc3fec6c266cd974be776a9e92759b90f288dcc2525b6b6bd5622c5f02e0922440e9ad1095c19b4467fd94566caa9755669d8e0000000180800000000400f64081ae6209dce9245753a4f764d6f168aae1af00000000000000000000000000000064000041dbcc7391991c1a18371eb49b879240247a3ec7f281328f53976c1218ffd65421dbb101e59370e2c972b29f48dc674b2de5e1b65acbd41d5d2689124d42c16c01010000000000051a346048df62be3a52bb6236e11394e8600229e27b000000000000271000000000000000000000000000000000000000000000000000000000000000000000" @@ -962,6 +978,13 @@ paths: description: Detailed error message example: $ref: "./components/examples/get-stacker-set-400.example.json" + "404": + description: Reward cycle not found or does not exist + content: + text/plain: + schema: + type: string + example: "No such file or directory" /v3/blocks/{block_id}: get: @@ -979,10 +1002,15 @@ paths: parameters: - name: block_id in: path - description: The block"s ID hash + description: The block's ID hash (64-character hex string) required: true schema: type: string + pattern: "^[0-9a-f]{64}$" + minLength: 64 + maxLength: 64 + examples: + - "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" responses: "200": description: The raw SIP-003-encoded block will be returned. @@ -995,6 +1023,8 @@ paths: $ref: "#/components/responses/BadRequest" "404": $ref: "#/components/responses/NotFound" + "405": + $ref: "#/components/responses/MethodNotAllowed" "500": $ref: "#/components/responses/InternalServerError" @@ -1066,10 +1096,15 @@ paths: parameters: - name: block_id in: path - description: The tenure-start block ID of the tenure to query + description: The tenure-start block ID of the tenure to query (64-character hex string) required: true schema: type: string + pattern: "^[0-9a-f]{64}$" + minLength: 64 + maxLength: 64 + examples: + - "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" - name: stop in: query description: | @@ -1080,6 +1115,9 @@ paths: required: false schema: type: string + pattern: "^[0-9a-f]{64}$" + minLength: 64 + maxLength: 64 responses: "200": description: SIP-003-encoded Nakamoto blocks, concatenated together @@ -1092,6 +1130,8 @@ paths: $ref: "#/components/responses/BadRequest" "404": $ref: "#/components/responses/NotFound" + "405": + $ref: "#/components/responses/MethodNotAllowed" "500": $ref: "#/components/responses/InternalServerError" @@ -1514,12 +1554,12 @@ paths: - name: pages_indexes in: query required: true - description: Comma-separated list of page indexes to query (max 8 pages, each index max 10 digits) + description: Comma-separated list of page indexes to query (max 8 pages, each index 0-4294967295) schema: type: string example: "1,2,3" pattern: "^[0-9]{1,10}(,[0-9]{1,10}){0,7}$" - description: max 8 pages per request, each page index limited to 10 digits + description: max 8 pages per request, each page index max 10 digits (API validates u32 range) responses: "200": description: Attachment inventory bitfield @@ -1694,7 +1734,7 @@ paths: description: | Get the latest version of a chunk of data from a StackerDB instance. parameters: - - $ref: ./components/parameters/principal.yaml + - $ref: ./components/parameters/standard-principal.yaml - $ref: ./components/parameters/contract-name.yaml - name: slot_id in: path @@ -1728,7 +1768,7 @@ paths: description: | Get a specific version of a chunk of data from a StackerDB instance. parameters: - - $ref: ./components/parameters/principal.yaml + - $ref: ./components/parameters/standard-principal.yaml - $ref: ./components/parameters/contract-name.yaml - name: slot_id in: path @@ -1769,7 +1809,7 @@ paths: description: | Get metadata about a StackerDB instance, including slot information. parameters: - - $ref: ./components/parameters/principal.yaml + - $ref: ./components/parameters/standard-principal.yaml - $ref: ./components/parameters/contract-name.yaml responses: "200": @@ -1807,7 +1847,7 @@ paths: detailed error information. Note that failed writes return HTTP 200 with accepted: false, not HTTP error codes. parameters: - - $ref: ./components/parameters/principal.yaml + - $ref: ./components/parameters/standard-principal.yaml - $ref: ./components/parameters/contract-name.yaml requestBody: required: true @@ -1851,7 +1891,7 @@ paths: description: | Get a list of replicas for a StackerDB instance. parameters: - - $ref: ./components/parameters/principal.yaml + - $ref: ./components/parameters/standard-principal.yaml - $ref: ./components/parameters/contract-name.yaml responses: "200": @@ -1882,14 +1922,14 @@ paths: Fetch a data variable from a smart contract. Returns the raw hex-encoded value of the variable. parameters: - - $ref: ./components/parameters/principal.yaml + - $ref: ./components/parameters/standard-principal.yaml - $ref: ./components/parameters/contract-name.yaml - name: var_name in: path required: true - description: Variable name + description: Variable name (must be a valid Clarity name) schema: - type: string + $ref: "#/components/schemas/ClarityName" - $ref: ./components/parameters/proof.yaml - $ref: ./components/parameters/tip.yaml responses: From 66f1356ff67b0d57715bd79141b9d4c3b29a9610 Mon Sep 17 00:00:00 2001 From: Simone Orsi <241460653+simone-stacks@users.noreply.github.com> Date: Thu, 11 Dec 2025 04:34:15 -0600 Subject: [PATCH 03/15] remove unused example files --- .../examples/fee-transaction-request.example.json | 4 ---- .../examples/post-block-proposal-request.example.json | 4 ---- .../examples/stackerdb-chunk-data-request.example.json | 6 ------ 3 files changed, 14 deletions(-) delete mode 100644 docs/rpc/components/examples/fee-transaction-request.example.json delete mode 100644 docs/rpc/components/examples/post-block-proposal-request.example.json delete mode 100644 docs/rpc/components/examples/stackerdb-chunk-data-request.example.json diff --git a/docs/rpc/components/examples/fee-transaction-request.example.json b/docs/rpc/components/examples/fee-transaction-request.example.json deleted file mode 100644 index daa4dfd486a..00000000000 --- a/docs/rpc/components/examples/fee-transaction-request.example.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "estimated_len": 350, - "transaction_payload": "021af942874ce525e87f21bbe8c121b12fac831d02f4086765742d696e666f0b7570646174652d696e666f00000000" -} diff --git a/docs/rpc/components/examples/post-block-proposal-request.example.json b/docs/rpc/components/examples/post-block-proposal-request.example.json deleted file mode 100644 index d32f2b6e53a..00000000000 --- a/docs/rpc/components/examples/post-block-proposal-request.example.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "block": "00000000000000001f00000000000927c08fb5ae5bf80e39e4168f6a3fddb0407a069d21ee68465e6856393254d2a66194f44bb01070666d5effcfb2436e209a75878fe80a04b4258a8cd34ab97c38a8dde331a2a509dd7e4b90590726866172cc138c18e80567737667f55d3f9817ce4714c91d1adfd36101141829dc0b5ea0c4944668c0005ddb6f9e2718f60014f21932a42a36ffaf58e88e77b217b2af366c15dd59e6b136ca773729832dcfc5875ec0830d04012dd5a4fa77a196646ea2b356289116fd02558c034b62d63f8a65bdd20d7ffc3fec6c266cd974be776a9e92759b90f288dcc2525b6b6bd5622c5f02e0922440e9ad1095c19b4467fd94566caa9755669d8e0000000180800000000400f64081ae6209dce9245753a4f764d6f168aae1af00000000000000000000000000000064000041dbcc7391991c1a18371eb49b879240247a3ec7f281328f53976c1218ffd65421dbb101e59370e2c972b29f48dc674b2de5e1b65acbd41d5d2689124d42c16c01010000000000051a346048df62be3a52bb6236e11394e8600229e27b000000000000271000000000000000000000000000000000000000000000000000000000000000000000", - "chain_id": 2147483648 -} diff --git a/docs/rpc/components/examples/stackerdb-chunk-data-request.example.json b/docs/rpc/components/examples/stackerdb-chunk-data-request.example.json deleted file mode 100644 index b5de8f43b1c..00000000000 --- a/docs/rpc/components/examples/stackerdb-chunk-data-request.example.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "slot_id": 1, - "slot_version": 2, - "sig": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef01", - "data": "deadbeefcafebabe" -} From 1d4ee1a28aad25893151cce2b0496fd126e6a2a7 Mon Sep 17 00:00:00 2001 From: Simone Orsi <241460653+simone-stacks@users.noreply.github.com> Date: Thu, 11 Dec 2025 04:59:22 -0600 Subject: [PATCH 04/15] keep redocly happy --- docs/rpc/components/examples/pox-info.example.json | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/rpc/components/examples/pox-info.example.json b/docs/rpc/components/examples/pox-info.example.json index 1659052c829..7bdd6ab0595 100644 --- a/docs/rpc/components/examples/pox-info.example.json +++ b/docs/rpc/components/examples/pox-info.example.json @@ -3,6 +3,7 @@ "pox_activation_threshold_ustx": 52329761604388, "first_burnchain_block_height": 666050, "current_burnchain_block_height": 812345, + "current_epoch": "Epoch30", "prepare_phase_block_length": 100, "reward_phase_block_length": 2000, "reward_slots": 4000, From 031804a28c01497714a4db5533b5c0416496ffaf Mon Sep 17 00:00:00 2001 From: Simone Orsi <241460653+simone-stacks@users.noreply.github.com> Date: Thu, 11 Dec 2025 04:59:42 -0600 Subject: [PATCH 05/15] create principals schemas and re-add tip pattern and maxLength --- .../components/examples/pox-info.example.json | 2 +- .../parameters/contract-address.yaml | 9 ++----- .../components/parameters/contract-name.yaml | 1 + docs/rpc/components/parameters/principal.yaml | 10 ++----- .../parameters/standard-principal.yaml | 11 +++----- docs/rpc/components/parameters/tip.yaml | 2 ++ .../components/schemas/principal.schema.yaml | 9 +++++++ .../read-only-function-args.schema.yaml | 26 +++++-------------- .../schemas/standard-principal.schema.yaml | 8 ++++++ docs/rpc/openapi.yaml | 6 +---- 10 files changed, 35 insertions(+), 49 deletions(-) create mode 100644 docs/rpc/components/schemas/principal.schema.yaml create mode 100644 docs/rpc/components/schemas/standard-principal.schema.yaml diff --git a/docs/rpc/components/examples/pox-info.example.json b/docs/rpc/components/examples/pox-info.example.json index 7bdd6ab0595..6b0861592dd 100644 --- a/docs/rpc/components/examples/pox-info.example.json +++ b/docs/rpc/components/examples/pox-info.example.json @@ -3,7 +3,7 @@ "pox_activation_threshold_ustx": 52329761604388, "first_burnchain_block_height": 666050, "current_burnchain_block_height": 812345, - "current_epoch": "Epoch30", + "current_epoch": "Epoch33", "prepare_phase_block_length": 100, "reward_phase_block_length": 2000, "reward_slots": 4000, diff --git a/docs/rpc/components/parameters/contract-address.yaml b/docs/rpc/components/parameters/contract-address.yaml index b8bae7489ab..e8e0af2da0a 100644 --- a/docs/rpc/components/parameters/contract-address.yaml +++ b/docs/rpc/components/parameters/contract-address.yaml @@ -1,3 +1,4 @@ +# Deployer address for contract endpoints (standard principal format) name: contract_address in: path required: true @@ -6,10 +7,4 @@ description: | Must start with SP (mainnet) or ST (testnet), followed by 26-39 c32check characters. Note: The API validates the cryptographic checksum internally; invalid addresses return appropriate errors. schema: - type: string - pattern: "^S[PT][0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26,39}$" - minLength: 28 - maxLength: 41 - examples: - - SP31DA6FTSJX2WGTZ69SFY11BH51NZMB0ZW97B5P0 - - ST000000000000000000002AMW42H + $ref: '../schemas/standard-principal.schema.yaml' diff --git a/docs/rpc/components/parameters/contract-name.yaml b/docs/rpc/components/parameters/contract-name.yaml index 58353c1a6ef..c64ced042c2 100644 --- a/docs/rpc/components/parameters/contract-name.yaml +++ b/docs/rpc/components/parameters/contract-name.yaml @@ -1,3 +1,4 @@ +# Contract identifier (letters, numbers, hyphens, underscores) name: contract_name in: path required: true diff --git a/docs/rpc/components/parameters/principal.yaml b/docs/rpc/components/parameters/principal.yaml index 3cb818172bb..a1cd5ec2b41 100644 --- a/docs/rpc/components/parameters/principal.yaml +++ b/docs/rpc/components/parameters/principal.yaml @@ -1,3 +1,4 @@ +# Standard principal (SP...) or contract principal (SP....contract-name) name: principal in: path required: true @@ -7,11 +8,4 @@ description: | Address must start with SP (mainnet) or ST (testnet) followed by c32check characters. Note: The API validates the cryptographic checksum internally; invalid addresses return appropriate errors. schema: - type: string - pattern: "^S[PT][0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26,39}(\\.[a-zA-Z]([a-zA-Z0-9]|[-_]){0,127})?$" - minLength: 28 - maxLength: 170 - examples: - - SP31DA6FTSJX2WGTZ69SFY11BH51NZMB0ZW97B5P0 - - SP31DA6FTSJX2WGTZ69SFY11BH51NZMB0ZW97B5P0.get-info - - ST000000000000000000002AMW42H + $ref: '../schemas/principal.schema.yaml' diff --git a/docs/rpc/components/parameters/standard-principal.yaml b/docs/rpc/components/parameters/standard-principal.yaml index 3c36b9a408a..8d6076abedf 100644 --- a/docs/rpc/components/parameters/standard-principal.yaml +++ b/docs/rpc/components/parameters/standard-principal.yaml @@ -1,15 +1,10 @@ +# Standard principal only (SP... or ST...), used by StackerDB endpoints name: principal in: path required: true description: | - Stacks address (standard principal, not contract principal). + Standard Stacks address (standard principal, not contract principal). Must start with SP (mainnet) or ST (testnet), followed by 26-39 c32check characters. Note: The API validates the cryptographic checksum internally; invalid addresses return appropriate errors. schema: - type: string - pattern: "^S[PT][0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26,39}$" - minLength: 28 - maxLength: 41 - examples: - - SP31DA6FTSJX2WGTZ69SFY11BH51NZMB0ZW97B5P0 - - ST000000000000000000002AMW42H + $ref: '../schemas/standard-principal.schema.yaml' diff --git a/docs/rpc/components/parameters/tip.yaml b/docs/rpc/components/parameters/tip.yaml index 879ad569b89..ff8502d368c 100644 --- a/docs/rpc/components/parameters/tip.yaml +++ b/docs/rpc/components/parameters/tip.yaml @@ -2,6 +2,8 @@ name: tip in: query schema: type: string + pattern: "^(latest|[0-9a-f]{64})?$" + maxLength: 64 example: latest description: | Stacks chain tip to query from. Options: diff --git a/docs/rpc/components/schemas/principal.schema.yaml b/docs/rpc/components/schemas/principal.schema.yaml new file mode 100644 index 00000000000..767e8dc7e39 --- /dev/null +++ b/docs/rpc/components/schemas/principal.schema.yaml @@ -0,0 +1,9 @@ +# Standard principal (SP...) or contract principal (SP....contract-name) +type: string +pattern: "^S[PT][0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26,39}(\\.[a-zA-Z]([a-zA-Z0-9]|[-_]){0,127})?$" +minLength: 28 +maxLength: 170 +examples: + - SP31DA6FTSJX2WGTZ69SFY11BH51NZMB0ZW97B5P0 + - SP31DA6FTSJX2WGTZ69SFY11BH51NZMB0ZW97B5P0.get-info + - ST000000000000000000002AMW42H diff --git a/docs/rpc/components/schemas/read-only-function-args.schema.yaml b/docs/rpc/components/schemas/read-only-function-args.schema.yaml index 9233863509c..7525b09c84a 100644 --- a/docs/rpc/components/schemas/read-only-function-args.schema.yaml +++ b/docs/rpc/components/schemas/read-only-function-args.schema.yaml @@ -6,27 +6,13 @@ required: - arguments properties: sender: - type: string - description: | - The simulated tx-sender (standard principal or contract principal). - Must start with SP (mainnet) or ST (testnet). - pattern: "^S[PT][0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26,39}(\\.[a-zA-Z]([a-zA-Z0-9]|[-_]){0,127})?$" - minLength: 28 - maxLength: 170 - examples: - - SP31DA6FTSJX2WGTZ69SFY11BH51NZMB0ZW97B5P0 - - ST000000000000000000002AMW42H + description: The simulated tx-sender (standard principal or contract principal). + allOf: + - $ref: './principal.schema.yaml' sponsor: - type: string - description: | - The simulated sponsor address. - Must start with SP (mainnet) or ST (testnet). - pattern: "^S[PT][0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26,39}(\\.[a-zA-Z]([a-zA-Z0-9]|[-_]){0,127})?$" - minLength: 28 - maxLength: 170 - examples: - - SP31DA6FTSJX2WGTZ69SFY11BH51NZMB0ZW97B5P0 - - ST000000000000000000002AMW42H + description: The simulated sponsor address. + allOf: + - $ref: './principal.schema.yaml' arguments: type: array description: An array of hex serialized Clarity values diff --git a/docs/rpc/components/schemas/standard-principal.schema.yaml b/docs/rpc/components/schemas/standard-principal.schema.yaml new file mode 100644 index 00000000000..1041abe7ce7 --- /dev/null +++ b/docs/rpc/components/schemas/standard-principal.schema.yaml @@ -0,0 +1,8 @@ +# Standard principal only (SP... or ST...), no contract suffix +type: string +pattern: "^S[PT][0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26,39}$" +minLength: 28 +maxLength: 41 +examples: + - SP31DA6FTSJX2WGTZ69SFY11BH51NZMB0ZW97B5P0 + - ST000000000000000000002AMW42H diff --git a/docs/rpc/openapi.yaml b/docs/rpc/openapi.yaml index 6d92d75fc97..ed12a174a5b 100644 --- a/docs/rpc/openapi.yaml +++ b/docs/rpc/openapi.yaml @@ -702,11 +702,7 @@ paths: Stacks address of the trait-defining contract. Must start with SP (mainnet) or ST (testnet). schema: - type: string - pattern: "^S[PT][0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26,39}$" - minLength: 28 - maxLength: 41 - example: "SP31DA6FTSJX2WGTZ69SFY11BH51NZMB0ZW97B5P0" + $ref: './components/schemas/standard-principal.schema.yaml' - name: trait_contract_name in: path required: true From 8206dccb4f058efa37b318817c1a0092b5f9664b Mon Sep 17 00:00:00 2001 From: Simone Orsi <241460653+simone-stacks@users.noreply.github.com> Date: Thu, 11 Dec 2025 05:04:27 -0600 Subject: [PATCH 06/15] improve OpenAPI schemas with u32 range constraints --- docs/rpc/components/parameters/tip.yaml | 3 +-- .../rpc/components/schemas/pox-info.schema.yaml | 1 - .../schemas/stackerdb-chunk-data.schema.yaml | 12 ++++++++---- docs/rpc/openapi.yaml | 17 ++++++++++++----- 4 files changed, 21 insertions(+), 12 deletions(-) diff --git a/docs/rpc/components/parameters/tip.yaml b/docs/rpc/components/parameters/tip.yaml index ff8502d368c..7f3b60eb4d0 100644 --- a/docs/rpc/components/parameters/tip.yaml +++ b/docs/rpc/components/parameters/tip.yaml @@ -9,5 +9,4 @@ description: | Stacks chain tip to query from. Options: - (empty/omitted): Use latest anchored tip (canonical confirmed state) - `latest`: Use latest known tip including unconfirmed microblocks - - `{block_id}`: Use specific block ID (64 hex characters) - Note: Invalid or unrecognized tip values are ignored and the default tip is used. + - `{block_id}`: Use specific block ID (64 hex characters) \ No newline at end of file diff --git a/docs/rpc/components/schemas/pox-info.schema.yaml b/docs/rpc/components/schemas/pox-info.schema.yaml index 70277db5551..c2ea7a3847c 100644 --- a/docs/rpc/components/schemas/pox-info.schema.yaml +++ b/docs/rpc/components/schemas/pox-info.schema.yaml @@ -15,7 +15,6 @@ required: - reward_cycle_length - contract_versions - epochs - - current_epoch properties: contract_id: type: string diff --git a/docs/rpc/components/schemas/stackerdb-chunk-data.schema.yaml b/docs/rpc/components/schemas/stackerdb-chunk-data.schema.yaml index f31cafbb7e6..248ec4c0373 100644 --- a/docs/rpc/components/schemas/stackerdb-chunk-data.schema.yaml +++ b/docs/rpc/components/schemas/stackerdb-chunk-data.schema.yaml @@ -1,3 +1,5 @@ +# TODO: Check if maximum is actually needed or not + type: object required: - slot_id @@ -8,16 +10,18 @@ properties: slot_id: type: integer minimum: 0 - description: Slot identifier + maximum: 4294967295 + description: Slot identifier (u32 range) slot_version: type: integer minimum: 0 - description: Slot version (lamport clock) + maximum: 4294967295 + description: Slot version (lamport clock, u32 range) sig: type: string description: Hex-encoded signature from the stacker pattern: "^[0-9a-f]{130}$" data: type: string - description: Hex-encoded chunk data - pattern: "^[0-9a-f]*$" + description: Hex-encoded chunk data (must be even length) + pattern: "^([0-9a-f]{2})*$" diff --git a/docs/rpc/openapi.yaml b/docs/rpc/openapi.yaml index ed12a174a5b..e219fdc8b06 100644 --- a/docs/rpc/openapi.yaml +++ b/docs/rpc/openapi.yaml @@ -1550,12 +1550,16 @@ paths: - name: pages_indexes in: query required: true - description: Comma-separated list of page indexes to query (max 8 pages, each index 0-4294967295) + description: | + Comma-separated list of page indexes to query. + - Maximum 8 pages per request + - Each index must be 0-4294967295 (u32 range) + - Values outside u32 range return 400 Bad Request schema: type: string example: "1,2,3" + # Pattern allows up to 10 digits; API validates u32 range at runtime pattern: "^[0-9]{1,10}(,[0-9]{1,10}){0,7}$" - description: max 8 pages per request, each page index max 10 digits (API validates u32 range) responses: "200": description: Attachment inventory bitfield @@ -1735,10 +1739,11 @@ paths: - name: slot_id in: path required: true - description: Slot ID + description: Slot ID (u32 range) schema: type: integer minimum: 0 + maximum: 4294967295 responses: "200": description: StackerDB chunk data @@ -1769,17 +1774,19 @@ paths: - name: slot_id in: path required: true - description: Slot ID + description: Slot ID (u32 range) schema: type: integer minimum: 0 + maximum: 4294967295 - name: slot_version in: path required: true - description: Specific slot version + description: Specific slot version (u32 range) schema: type: integer minimum: 0 + maximum: 4294967295 responses: "200": description: StackerDB chunk data From 19b0984b16cbbf269e3d807718d81bc8391c6133 Mon Sep 17 00:00:00 2001 From: Simone Orsi <241460653+simone-stacks@users.noreply.github.com> Date: Thu, 11 Dec 2025 09:05:42 -0600 Subject: [PATCH 07/15] implement proper 405/400/404 HTTP error responses with Allow header --- stackslib/src/net/api/getblock_v3.rs | 5 + stackslib/src/net/api/gettenure.rs | 5 + stackslib/src/net/http/error.rs | 28 ++++ stackslib/src/net/http/mod.rs | 4 +- stackslib/src/net/http/request.rs | 7 + stackslib/src/net/httpcore.rs | 191 +++++++++++++++++++++------ 6 files changed, 194 insertions(+), 46 deletions(-) diff --git a/stackslib/src/net/api/getblock_v3.rs b/stackslib/src/net/api/getblock_v3.rs index dffb2c38f35..cf6483af30a 100644 --- a/stackslib/src/net/api/getblock_v3.rs +++ b/stackslib/src/net/api/getblock_v3.rs @@ -117,6 +117,11 @@ impl HttpRequest for RPCNakamotoBlockRequestHandler { Regex::new(r#"^/v3/blocks/(?P[0-9a-f]{64})$"#).unwrap() } + fn path_regex_permissive(&self) -> Regex { + // Permissive regex for 405 detection - matches any block_id format + Regex::new(r#"^/v3/blocks/[^/]+$"#).unwrap() + } + fn metrics_identifier(&self) -> &str { "/v3/blocks/:block_id" } diff --git a/stackslib/src/net/api/gettenure.rs b/stackslib/src/net/api/gettenure.rs index 1f026d80ecc..dc4570053e4 100644 --- a/stackslib/src/net/api/gettenure.rs +++ b/stackslib/src/net/api/gettenure.rs @@ -146,6 +146,11 @@ impl HttpRequest for RPCNakamotoTenureRequestHandler { Regex::new(r#"^/v3/tenures/(?P[0-9a-f]{64})$"#).unwrap() } + fn path_regex_permissive(&self) -> Regex { + // Permissive regex for 405 detection - matches any block_id format + Regex::new(r#"^/v3/tenures/[^/]+$"#).unwrap() + } + fn metrics_identifier(&self) -> &str { "/v3/tenures/:block_id" } diff --git a/stackslib/src/net/http/error.rs b/stackslib/src/net/http/error.rs index 31b91a76945..cbc185e58ab 100644 --- a/stackslib/src/net/http/error.rs +++ b/stackslib/src/net/http/error.rs @@ -128,6 +128,7 @@ pub fn http_error_from_code_and_text(code: u16, message: String) -> Box Box::new(HttpPaymentRequired::new(message)), 403 => Box::new(HttpForbidden::new(message)), 404 => Box::new(HttpNotFound::new(message)), + 405 => Box::new(HttpMethodNotAllowed::new(message)), 408 => Box::new(HttpRequestTimeout::new(message)), 500 => Box::new(HttpServerError::new(message)), 501 => Box::new(HttpNotImplemented::new(message)), @@ -293,6 +294,33 @@ impl HttpErrorResponse for HttpNotFound { } } +/// HTTP 405 +pub struct HttpMethodNotAllowed { + error_text: String, +} + +impl HttpMethodNotAllowed { + pub fn new(error_text: String) -> Self { + Self { error_text } + } +} + +impl HttpErrorResponse for HttpMethodNotAllowed { + fn code(&self) -> u16 { + 405 + } + fn payload(&self) -> HttpResponsePayload { + HttpResponsePayload::Text(self.error_text.clone()) + } + fn try_parse_response( + &self, + preamble: &HttpResponsePreamble, + body: &[u8], + ) -> Result { + try_parse_error_response(preamble.status_code, preamble.content_type, body) + } +} + /// HTTP 408 pub struct HttpRequestTimeout { error_text: String, diff --git a/stackslib/src/net/http/mod.rs b/stackslib/src/net/http/mod.rs index ce7c00dc066..95697f294ac 100644 --- a/stackslib/src/net/http/mod.rs +++ b/stackslib/src/net/http/mod.rs @@ -37,8 +37,8 @@ pub use crate::net::http::common::{ }; pub use crate::net::http::error::{ http_error_from_code_and_text, http_reason, HttpBadRequest, HttpError, HttpErrorResponse, - HttpForbidden, HttpNotFound, HttpNotImplemented, HttpPaymentRequired, HttpRequestTimeout, - HttpServerError, HttpServiceUnavailable, HttpUnauthorized, + HttpForbidden, HttpMethodNotAllowed, HttpNotFound, HttpNotImplemented, HttpPaymentRequired, + HttpRequestTimeout, HttpServerError, HttpServiceUnavailable, HttpUnauthorized, }; pub use crate::net::http::request::{ HttpRequest, HttpRequestContents, HttpRequestPayload, HttpRequestPreamble, diff --git a/stackslib/src/net/http/request.rs b/stackslib/src/net/http/request.rs index 6b5b1a6e19b..965a3285413 100644 --- a/stackslib/src/net/http/request.rs +++ b/stackslib/src/net/http/request.rs @@ -694,6 +694,13 @@ pub trait HttpRequest: Send + HttpRequestClone { fn verb(&self) -> &'static str; /// What is the path regex that this request honors? fn path_regex(&self) -> Regex; + /// This regex matches the path structure without strict parameter validation, + /// allowing the API to return 405 Method Not Allowed when the path exists + /// but the HTTP method is not supported. + /// Default implementation returns the same as path_regex(). + fn path_regex_permissive(&self) -> Regex { + self.path_regex() + } /// Decode a request into the contents that this request handler cares about. fn try_parse_request( &mut self, diff --git a/stackslib/src/net/httpcore.rs b/stackslib/src/net/httpcore.rs index 5502cb64e2f..679ca362eb1 100644 --- a/stackslib/src/net/httpcore.rs +++ b/stackslib/src/net/httpcore.rs @@ -26,29 +26,30 @@ use clarity::vm::types::QualifiedContractIdentifier; use clarity::vm::{ClarityName, ContractName}; use percent_encoding::percent_decode_str; use regex::{Captures, Regex}; -use stacks_common::codec::{read_next, Error as CodecError, StacksMessageCodec, MAX_MESSAGE_LEN}; +use stacks_common::codec::{Error as CodecError, MAX_MESSAGE_LEN, StacksMessageCodec, read_next}; +use stacks_common::types::Address; use stacks_common::types::chainstate::{ BurnchainHeaderHash, ConsensusHash, StacksAddress, StacksBlockId, StacksPublicKey, }; use stacks_common::types::net::PeerHost; -use stacks_common::types::Address; use stacks_common::util::chunked_encoding::*; use stacks_common::util::retry::{BoundReader, RetryReader}; use stacks_common::util::{get_epoch_time_ms, get_epoch_time_secs}; use url::Url; use crate::burnchains::Txid; -use crate::chainstate::burn::db::sortdb::SortitionDB; use crate::chainstate::burn::BlockSnapshot; +use crate::chainstate::burn::db::sortdb::SortitionDB; use crate::chainstate::nakamoto::NakamotoChainState; use crate::chainstate::stacks::db::{StacksChainState, StacksHeaderInfo}; use crate::core::StacksEpoch; use crate::net::connection::{ConnectionOptions, NetworkConnection}; -use crate::net::http::common::{parse_raw_bytes, HTTP_PREAMBLE_MAX_ENCODED_SIZE}; +use crate::net::http::common::{HTTP_PREAMBLE_MAX_ENCODED_SIZE, parse_raw_bytes}; use crate::net::http::{ - http_reason, parse_bytes, parse_json, Error as HttpError, HttpContentType, HttpErrorResponse, - HttpNotFound, HttpRequest, HttpRequestContents, HttpRequestPreamble, HttpResponse, - HttpResponseContents, HttpResponsePayload, HttpResponsePreamble, HttpServerError, + Error as HttpError, HttpContentType, HttpErrorResponse, HttpMethodNotAllowed, HttpNotFound, + HttpRequest, HttpRequestContents, HttpRequestPreamble, HttpResponse, HttpResponseContents, + HttpResponsePayload, HttpResponsePreamble, HttpServerError, http_reason, parse_bytes, + parse_json, }; use crate::net::p2p::PeerNetwork; use crate::net::server::HttpPeer; @@ -56,6 +57,9 @@ use crate::net::{Error as NetError, MessageSequence, ProtocolFamily, StacksNodeS const CHUNK_BUF_LEN: usize = 32768; +/// Prefix for 405 error messages - used to extract allowed methods for the Allow header +const METHOD_NOT_ALLOWED_MSG_PREFIX: &str = "Method Not Allowed. Allowed: "; + /// canonical stacks tip height header pub const STACKS_HEADER_HEIGHT: &str = "X-Canonical-Stacks-Tip-Height"; @@ -656,6 +660,15 @@ impl StacksHttpResponse { pub fn new_error( preamble: &HttpRequestPreamble, error: &dyn HttpErrorResponse, + ) -> StacksHttpResponse { + Self::new_error_with_headers(preamble, error, vec![]) + } + + /// Make a new HTTP error response with additional headers, in reaction to a request + pub fn new_error_with_headers( + preamble: &HttpRequestPreamble, + error: &dyn HttpErrorResponse, + extra_headers: Vec<(String, String)>, ) -> StacksHttpResponse { let payload = error.payload(); let content_type = match &payload { @@ -665,14 +678,17 @@ impl StacksHttpResponse { HttpResponsePayload::JSON(..) => HttpContentType::JSON, }; let content_length = payload.try_content_length(); - let preamble = HttpResponsePreamble::from_http_request_preamble( + let mut resp_preamble = HttpResponsePreamble::from_http_request_preamble( preamble, error.code(), http_reason(error.code()), content_length, content_type, ); - StacksHttpResponse::new(preamble, payload) + for (key, value) in extra_headers { + resp_preamble.add_header(key, value); + } + StacksHttpResponse::new(resp_preamble, payload) } /// Make a new HTTP error response for text, apropos of nothing @@ -780,15 +796,25 @@ impl StacksMessageCodec for StacksHttpPreamble { // underflow? match (e_request, e) { (CodecError::ReadError(ref ioe1), CodecError::ReadError(ref ioe2)) => { - if ioe1.kind() == io::ErrorKind::UnexpectedEof && ioe2.kind() == io::ErrorKind::UnexpectedEof { + if ioe1.kind() == io::ErrorKind::UnexpectedEof + && ioe2.kind() == io::ErrorKind::UnexpectedEof + { // out of bytes - Err(CodecError::UnderflowError("Not enough bytes to form a HTTP request or response".to_string())) - } - else { - Err(CodecError::DeserializeError(format!("Neither a HTTP request ({:?}) or HTTP response ({:?})", ioe1, ioe2))) + Err(CodecError::UnderflowError( + "Not enough bytes to form a HTTP request or response" + .to_string(), + )) + } else { + Err(CodecError::DeserializeError(format!( + "Neither a HTTP request ({:?}) or HTTP response ({:?})", + ioe1, ioe2 + ))) } - }, - (e_req, e_res) => Err(CodecError::DeserializeError(format!("Failed to decode HTTP request or HTTP response (request error: {:?}; response error: {:?})", &e_req, &e_res))) + } + (e_req, e_res) => Err(CodecError::DeserializeError(format!( + "Failed to decode HTTP request or HTTP response (request error: {:?}; response error: {:?})", + &e_req, &e_res + ))), } } } @@ -965,8 +991,9 @@ pub struct StacksHttp { /// parse a reply. If instead this state-machine is used by the server to parse a request and /// send a reply, it will be unused. request_handler_index: Option, - /// HTTP request handlers (verb, regex, request-handler, response-handler) - request_handlers: Vec<(String, Regex, Box)>, + /// HTTP request handlers (verb, regex, permissive_regex, request-handler) + /// The permissive_regex is used for 405 Method Not Allowed detection + request_handlers: Vec<(String, Regex, Regex, Box)>, /// Maximum size of call arguments pub maximum_call_argument_size: u32, /// Maximum execution budget of a read-only call @@ -1034,6 +1061,7 @@ impl StacksHttp { self.request_handlers.push(( handler.verb().to_string(), handler.path_regex(), + handler.path_regex_permissive(), Box::new(handler), )); } @@ -1041,7 +1069,7 @@ impl StacksHttp { /// Find the HTTP request handler to use to process the reply, given the request path. /// Returns the index into the list of handlers fn find_response_handler(&self, request_verb: &str, request_path: &str) -> Option { - for (i, (verb, regex, _)) in self.request_handlers.iter().enumerate() { + for (i, (verb, regex, _, _)) in self.request_handlers.iter().enumerate() { if request_verb != verb { continue; } @@ -1054,6 +1082,21 @@ impl StacksHttp { None } + /// Find all allowed HTTP methods for a given path. + /// Returns a list of HTTP verbs that are allowed for handlers whose path regex matches. + fn find_allowed_methods(&self, request_path: &str) -> Vec { + let mut allowed_methods = Vec::new(); + for (verb, regex, permissive_regex, _) in self.request_handlers.iter() { + // Check if either the strict or permissive regex matches + if regex.is_match(request_path) || permissive_regex.is_match(request_path) { + if !allowed_methods.contains(verb) { + allowed_methods.push(verb.clone()); + } + } + } + allowed_methods + } + /// Force the state machine to expect a response #[cfg(test)] pub fn set_response_handler(&mut self, request_verb: &str, request_path: &str) { @@ -1108,17 +1151,28 @@ impl StacksHttp { let (decoded_path, query) = decode_request_path(&preamble.path_and_query_str)?; test_debug!("decoded_path: '{}', query: '{}'", &decoded_path, &query); - // NOTE: This loop starts out like `find_response_handler()`, but `captures`'s lifetime is - // bound to `regex` so we can't just return it from `find_response_handler()`. Thus, it's - // duplicated here. - for (verb, regex, request) in self.request_handlers.iter_mut() { - if &preamble.verb != verb { - continue; - } + let mut allowed_methods: Vec = Vec::new(); + let mut verb_matched_but_params_invalid = false; + let mut any_strict_match = false; + + for (verb, regex, permissive_regex, request) in self.request_handlers.iter_mut() { + let permissive_match = permissive_regex.is_match(&decoded_path); let Some(captures) = regex.captures(&decoded_path) else { + if permissive_match { + if &preamble.verb == verb { + verb_matched_but_params_invalid = true; + } + allowed_methods.push(verb.to_string()); + } continue; }; + any_strict_match = true; + allowed_methods.push(verb.to_string()); + if &preamble.verb != verb { + continue; + } + let payload = match request.try_parse_request( preamble, &captures, @@ -1133,15 +1187,34 @@ impl StacksHttp { }; debug!("Handle StacksHttpRequest"; "verb" => %verb, "peer_addr" => %self.peer_addr, "path" => %decoded_path, "query" => %query); - let request = StacksHttpRequest::new(preamble.clone(), payload); - return Ok(request); + return Ok(StacksHttpRequest::new(preamble.clone(), payload)); } test_debug!("Failed to parse '{}'", &preamble.path_and_query_str); - Err(NetError::Http(HttpError::Http( - 404, - "No such file or directory".into(), - ))) + + // 400 if path pattern matched but params invalid (e.g. GET /v3/blocks/65-char-hex) + // 405 if path exists but wrong method (e.g. DELETE /v2/info) + // 404 if path doesn't exist + if !any_strict_match && verb_matched_but_params_invalid { + Err(NetError::Http(HttpError::Http( + 400, + "Invalid path parameters".into(), + ))) + } else if !allowed_methods.is_empty() { + Err(NetError::Http(HttpError::Http( + 405, + format!( + "{}{}", + METHOD_NOT_ALLOWED_MSG_PREFIX, + allowed_methods.join(", ") + ), + ))) + } else { + Err(NetError::Http(HttpError::Http( + 404, + "No such file or directory".into(), + ))) + } } /// Parse out an HTTP response error message @@ -1194,7 +1267,7 @@ impl StacksHttp { return Self::try_parse_error_response(preamble, body); } - let (_, _, parser) = self + let (_, _, _, parser) = self .request_handlers .get(request_handler_index) .expect("FATAL: tried to use nonexistent response handler"); @@ -1217,7 +1290,22 @@ impl StacksHttp { .response_handler_index .or_else(|| self.find_response_handler(&request.preamble().verb, &decoded_path)) else { - // method not found + // Handler not found - check if it's a method not allowed (405) or not found (404) + let allowed_methods = self.find_allowed_methods(&decoded_path); + if !allowed_methods.is_empty() { + // Path exists but method is not allowed (405) + let allowed_str = allowed_methods.join(", "); + return StacksHttpResponse::new_error_with_headers( + &request.preamble, + &HttpMethodNotAllowed::new(format!( + "{}{}", + METHOD_NOT_ALLOWED_MSG_PREFIX, &allowed_str + )), + vec![("Allow".to_string(), allowed_str)], + ) + .try_into_contents(); + } + // Path does not exist (404) return StacksHttpResponse::new_error( &request.preamble, &HttpNotFound::new(format!( @@ -1229,7 +1317,7 @@ impl StacksHttp { .try_into_contents(); }; - let (_, _, request_handler) = self + let (_, _, _, request_handler) = self .request_handlers .get_mut(response_handler_index) .expect("FATAL: request points to a nonexistent handler"); @@ -1385,7 +1473,7 @@ impl StacksHttp { }; req.response_handler_index = Some(response_handler_index); - let (_, _, request_handler) = self + let (_, _, _, request_handler) = self .request_handlers .get(response_handler_index) .expect("FATAL: request points to a nonexistent handler"); @@ -1627,9 +1715,22 @@ impl ProtocolFamily for StacksHttp { Ok(data_request) => Ok((StacksHttpMessage::Request(data_request), len)), Err(NetError::Http(http_error)) => { // convert into a response - let resp = StacksHttpResponse::new_error( + // for 405 responses, extract allowed methods and add Allow header + let extra_headers = if let HttpError::Http(405, ref msg) = &http_error { + if let Some(allowed_part) = + msg.strip_prefix(METHOD_NOT_ALLOWED_MSG_PREFIX) + { + vec![("Allow".to_string(), allowed_part.to_string())] + } else { + vec![] + } + } else { + vec![] + }; + let resp = StacksHttpResponse::new_error_with_headers( http_request_preamble, &*http_error.into_http_error(), + extra_headers, ); self.reset(); return Ok(( @@ -1950,7 +2051,9 @@ pub fn send_http_request( if last_heartbeat_time.elapsed() >= HEARTBEAT_INTERVAL { info!( "send_request(sending data): heartbeat - still sending request to {} path='{}' (elapsed: {:?})", - addr, request_path, start.elapsed() + addr, + request_path, + start.elapsed() ); last_heartbeat_time = Instant::now(); } @@ -2002,7 +2105,9 @@ pub fn send_http_request( if last_heartbeat_time.elapsed() >= HEARTBEAT_INTERVAL { info!( "send_request(receiving data): heartbeat - still receiving response from {} path='{}' (elapsed: {:?})", - addr, request_path, start.elapsed() + addr, + request_path, + start.elapsed() ); last_heartbeat_time = Instant::now(); } @@ -2016,11 +2121,9 @@ pub fn send_http_request( let path = &request.preamble().path_and_query_str; let resp_status_code = response.preamble().status_code; let resp_body = response.body(); - return Err(io::Error::other( - format!( - "HTTP '{verb} {path}' did not succeed ({resp_status_code} != 200). Response body = {resp_body:?}" - ), - )); + return Err(io::Error::other(format!( + "HTTP '{verb} {path}' did not succeed ({resp_status_code} != 200). Response body = {resp_body:?}" + ))); } _ => { return Err(io::Error::other("Did not receive an HTTP response")); From e1a3b9c7c247c4425b4f7b506679eafefc82f0f2 Mon Sep 17 00:00:00 2001 From: Simone Orsi <241460653+simone-stacks@users.noreply.github.com> Date: Thu, 11 Dec 2025 14:16:55 -0600 Subject: [PATCH 08/15] keep linters happy --- stackslib/src/net/httpcore.rs | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/stackslib/src/net/httpcore.rs b/stackslib/src/net/httpcore.rs index 679ca362eb1..634ac193711 100644 --- a/stackslib/src/net/httpcore.rs +++ b/stackslib/src/net/httpcore.rs @@ -26,30 +26,29 @@ use clarity::vm::types::QualifiedContractIdentifier; use clarity::vm::{ClarityName, ContractName}; use percent_encoding::percent_decode_str; use regex::{Captures, Regex}; -use stacks_common::codec::{Error as CodecError, MAX_MESSAGE_LEN, StacksMessageCodec, read_next}; -use stacks_common::types::Address; +use stacks_common::codec::{read_next, Error as CodecError, StacksMessageCodec, MAX_MESSAGE_LEN}; use stacks_common::types::chainstate::{ BurnchainHeaderHash, ConsensusHash, StacksAddress, StacksBlockId, StacksPublicKey, }; use stacks_common::types::net::PeerHost; +use stacks_common::types::Address; use stacks_common::util::chunked_encoding::*; use stacks_common::util::retry::{BoundReader, RetryReader}; use stacks_common::util::{get_epoch_time_ms, get_epoch_time_secs}; use url::Url; use crate::burnchains::Txid; -use crate::chainstate::burn::BlockSnapshot; use crate::chainstate::burn::db::sortdb::SortitionDB; +use crate::chainstate::burn::BlockSnapshot; use crate::chainstate::nakamoto::NakamotoChainState; use crate::chainstate::stacks::db::{StacksChainState, StacksHeaderInfo}; use crate::core::StacksEpoch; use crate::net::connection::{ConnectionOptions, NetworkConnection}; -use crate::net::http::common::{HTTP_PREAMBLE_MAX_ENCODED_SIZE, parse_raw_bytes}; +use crate::net::http::common::{parse_raw_bytes, HTTP_PREAMBLE_MAX_ENCODED_SIZE}; use crate::net::http::{ - Error as HttpError, HttpContentType, HttpErrorResponse, HttpMethodNotAllowed, HttpNotFound, - HttpRequest, HttpRequestContents, HttpRequestPreamble, HttpResponse, HttpResponseContents, - HttpResponsePayload, HttpResponsePreamble, HttpServerError, http_reason, parse_bytes, - parse_json, + http_reason, parse_bytes, parse_json, Error as HttpError, HttpContentType, HttpErrorResponse, + HttpMethodNotAllowed, HttpNotFound, HttpRequest, HttpRequestContents, HttpRequestPreamble, + HttpResponse, HttpResponseContents, HttpResponsePayload, HttpResponsePreamble, HttpServerError, }; use crate::net::p2p::PeerNetwork; use crate::net::server::HttpPeer; @@ -1154,7 +1153,7 @@ impl StacksHttp { let mut allowed_methods: Vec = Vec::new(); let mut verb_matched_but_params_invalid = false; let mut any_strict_match = false; - + for (verb, regex, permissive_regex, request) in self.request_handlers.iter_mut() { let permissive_match = permissive_regex.is_match(&decoded_path); let Some(captures) = regex.captures(&decoded_path) else { From d93faa610148248e2757be3f4119785b25162f2e Mon Sep 17 00:00:00 2001 From: Simone Orsi <241460653+simone-stacks@users.noreply.github.com> Date: Mon, 15 Dec 2025 04:38:04 -0600 Subject: [PATCH 09/15] add SM and SN in the principal patterns --- docs/rpc/components/parameters/contract-address.yaml | 3 +-- docs/rpc/components/parameters/contract-name.yaml | 1 - docs/rpc/components/parameters/principal.yaml | 4 +--- docs/rpc/components/parameters/standard-principal.yaml | 4 +--- docs/rpc/components/parameters/tip.yaml | 2 +- docs/rpc/components/schemas/principal.schema.yaml | 4 ++-- .../components/schemas/read-only-function-args.schema.yaml | 4 ++-- docs/rpc/components/schemas/standard-principal.schema.yaml | 4 ++-- 8 files changed, 10 insertions(+), 16 deletions(-) diff --git a/docs/rpc/components/parameters/contract-address.yaml b/docs/rpc/components/parameters/contract-address.yaml index e8e0af2da0a..7f33931777c 100644 --- a/docs/rpc/components/parameters/contract-address.yaml +++ b/docs/rpc/components/parameters/contract-address.yaml @@ -4,7 +4,6 @@ in: path required: true description: | Standard Stacks address (e.g. `SP31DA6FTSJX2WGTZ69SFY11BH51NZMB0ZW97B5P0`). - Must start with SP (mainnet) or ST (testnet), followed by 26-39 c32check characters. - Note: The API validates the cryptographic checksum internally; invalid addresses return appropriate errors. + Must be 28-41 characters long using Stacks base58check format. schema: $ref: '../schemas/standard-principal.schema.yaml' diff --git a/docs/rpc/components/parameters/contract-name.yaml b/docs/rpc/components/parameters/contract-name.yaml index c64ced042c2..58353c1a6ef 100644 --- a/docs/rpc/components/parameters/contract-name.yaml +++ b/docs/rpc/components/parameters/contract-name.yaml @@ -1,4 +1,3 @@ -# Contract identifier (letters, numbers, hyphens, underscores) name: contract_name in: path required: true diff --git a/docs/rpc/components/parameters/principal.yaml b/docs/rpc/components/parameters/principal.yaml index a1cd5ec2b41..df1e32b3cf3 100644 --- a/docs/rpc/components/parameters/principal.yaml +++ b/docs/rpc/components/parameters/principal.yaml @@ -1,11 +1,9 @@ -# Standard principal (SP...) or contract principal (SP....contract-name) name: principal in: path required: true description: | Stacks address or a Contract identifier in format `{address}.{contract_name}` (e.g. `SP31DA6FTSJX2WGTZ69SFY11BH51NZMB0ZW97B5P0.get-info`). - Address must start with SP (mainnet) or ST (testnet) followed by c32check characters. - Note: The API validates the cryptographic checksum internally; invalid addresses return appropriate errors. + Must be 28-41 characters long using Stacks c32check format. schema: $ref: '../schemas/principal.schema.yaml' diff --git a/docs/rpc/components/parameters/standard-principal.yaml b/docs/rpc/components/parameters/standard-principal.yaml index 8d6076abedf..fc708b83f4b 100644 --- a/docs/rpc/components/parameters/standard-principal.yaml +++ b/docs/rpc/components/parameters/standard-principal.yaml @@ -1,10 +1,8 @@ -# Standard principal only (SP... or ST...), used by StackerDB endpoints name: principal in: path required: true description: | Standard Stacks address (standard principal, not contract principal). - Must start with SP (mainnet) or ST (testnet), followed by 26-39 c32check characters. - Note: The API validates the cryptographic checksum internally; invalid addresses return appropriate errors. + Must be 28-41 characters long using Stacks c32check format. schema: $ref: '../schemas/standard-principal.schema.yaml' diff --git a/docs/rpc/components/parameters/tip.yaml b/docs/rpc/components/parameters/tip.yaml index 7f3b60eb4d0..d6a53a06133 100644 --- a/docs/rpc/components/parameters/tip.yaml +++ b/docs/rpc/components/parameters/tip.yaml @@ -9,4 +9,4 @@ description: | Stacks chain tip to query from. Options: - (empty/omitted): Use latest anchored tip (canonical confirmed state) - `latest`: Use latest known tip including unconfirmed microblocks - - `{block_id}`: Use specific block ID (64 hex characters) \ No newline at end of file + - `{block_id}`: Use specific block ID (64 hex characters) diff --git a/docs/rpc/components/schemas/principal.schema.yaml b/docs/rpc/components/schemas/principal.schema.yaml index 767e8dc7e39..d2e59e9c3ca 100644 --- a/docs/rpc/components/schemas/principal.schema.yaml +++ b/docs/rpc/components/schemas/principal.schema.yaml @@ -1,6 +1,6 @@ -# Standard principal (SP...) or contract principal (SP....contract-name) +# Standard principal or contract principal (address.contract-name) type: string -pattern: "^S[PT][0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26,39}(\\.[a-zA-Z]([a-zA-Z0-9]|[-_]){0,127})?$" +pattern: "^S[PTMN][0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26,39}(\\.[a-zA-Z]([a-zA-Z0-9]|[-_]){0,127})?$" minLength: 28 maxLength: 170 examples: diff --git a/docs/rpc/components/schemas/read-only-function-args.schema.yaml b/docs/rpc/components/schemas/read-only-function-args.schema.yaml index 7525b09c84a..bd67cf8ac3d 100644 --- a/docs/rpc/components/schemas/read-only-function-args.schema.yaml +++ b/docs/rpc/components/schemas/read-only-function-args.schema.yaml @@ -6,9 +6,9 @@ required: - arguments properties: sender: - description: The simulated tx-sender (standard principal or contract principal). + description: The simulated tx-sender. allOf: - - $ref: './principal.schema.yaml' + - $ref: './standard-principal.schema.yaml' sponsor: description: The simulated sponsor address. allOf: diff --git a/docs/rpc/components/schemas/standard-principal.schema.yaml b/docs/rpc/components/schemas/standard-principal.schema.yaml index 1041abe7ce7..f9a9d52f0e8 100644 --- a/docs/rpc/components/schemas/standard-principal.schema.yaml +++ b/docs/rpc/components/schemas/standard-principal.schema.yaml @@ -1,6 +1,6 @@ -# Standard principal only (SP... or ST...), no contract suffix +# Standard principal only, no contract suffix type: string -pattern: "^S[PT][0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26,39}$" +pattern: "^S[PTMN][0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26,39}$" minLength: 28 maxLength: 41 examples: From 3c98ef900ccd2878a5982005d1b138b77eff7c2d Mon Sep 17 00:00:00 2001 From: Simone Orsi <241460653+simone-stacks@users.noreply.github.com> Date: Mon, 15 Dec 2025 04:47:37 -0600 Subject: [PATCH 10/15] add current_epoch as a required field --- docs/rpc/components/schemas/pox-info.schema.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/rpc/components/schemas/pox-info.schema.yaml b/docs/rpc/components/schemas/pox-info.schema.yaml index c2ea7a3847c..70277db5551 100644 --- a/docs/rpc/components/schemas/pox-info.schema.yaml +++ b/docs/rpc/components/schemas/pox-info.schema.yaml @@ -15,6 +15,7 @@ required: - reward_cycle_length - contract_versions - epochs + - current_epoch properties: contract_id: type: string From c797975e9634fea32be0130b0d31167f4aa22e50 Mon Sep 17 00:00:00 2001 From: Simone Orsi <241460653+simone-stacks@users.noreply.github.com> Date: Mon, 15 Dec 2025 07:46:22 -0600 Subject: [PATCH 11/15] add SM SN examples and enforce even-length hex patterns --- docs/rpc/components/parameters/contract-address.yaml | 2 +- docs/rpc/components/parameters/principal.yaml | 4 ++-- docs/rpc/components/schemas/principal.schema.yaml | 2 ++ .../schemas/read-only-function-args.schema.yaml | 2 +- .../components/schemas/stackerdb-chunk-data.schema.yaml | 2 -- .../rpc/components/schemas/standard-principal.schema.yaml | 2 ++ docs/rpc/openapi.yaml | 8 ++++---- 7 files changed, 12 insertions(+), 10 deletions(-) diff --git a/docs/rpc/components/parameters/contract-address.yaml b/docs/rpc/components/parameters/contract-address.yaml index 7f33931777c..e4584026a4e 100644 --- a/docs/rpc/components/parameters/contract-address.yaml +++ b/docs/rpc/components/parameters/contract-address.yaml @@ -4,6 +4,6 @@ in: path required: true description: | Standard Stacks address (e.g. `SP31DA6FTSJX2WGTZ69SFY11BH51NZMB0ZW97B5P0`). - Must be 28-41 characters long using Stacks base58check format. + Must be 28-41 characters long using Stacks c32check format. schema: $ref: '../schemas/standard-principal.schema.yaml' diff --git a/docs/rpc/components/parameters/principal.yaml b/docs/rpc/components/parameters/principal.yaml index df1e32b3cf3..66d6a0c049b 100644 --- a/docs/rpc/components/parameters/principal.yaml +++ b/docs/rpc/components/parameters/principal.yaml @@ -2,8 +2,8 @@ name: principal in: path required: true description: | - Stacks address or a Contract identifier in format `{address}.{contract_name}` + Stacks address (28-41 characters) or a Contract identifier in format `{address}.{contract_name}` (e.g. `SP31DA6FTSJX2WGTZ69SFY11BH51NZMB0ZW97B5P0.get-info`). - Must be 28-41 characters long using Stacks c32check format. + Contract names have a maximum length of 40 characters for new contracts. Legacy contracts may have names up to 128 characters. schema: $ref: '../schemas/principal.schema.yaml' diff --git a/docs/rpc/components/schemas/principal.schema.yaml b/docs/rpc/components/schemas/principal.schema.yaml index d2e59e9c3ca..a66900ac9a6 100644 --- a/docs/rpc/components/schemas/principal.schema.yaml +++ b/docs/rpc/components/schemas/principal.schema.yaml @@ -7,3 +7,5 @@ examples: - SP31DA6FTSJX2WGTZ69SFY11BH51NZMB0ZW97B5P0 - SP31DA6FTSJX2WGTZ69SFY11BH51NZMB0ZW97B5P0.get-info - ST000000000000000000002AMW42H + - SM2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKQVX8X0G + - SN2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKP6D2ZK9 diff --git a/docs/rpc/components/schemas/read-only-function-args.schema.yaml b/docs/rpc/components/schemas/read-only-function-args.schema.yaml index bd67cf8ac3d..ee4ad260802 100644 --- a/docs/rpc/components/schemas/read-only-function-args.schema.yaml +++ b/docs/rpc/components/schemas/read-only-function-args.schema.yaml @@ -18,5 +18,5 @@ properties: description: An array of hex serialized Clarity values items: type: string - pattern: "^(0x)?[0-9a-fA-F]*$" + pattern: "^(0x)?([0-9a-fA-F]{2})+$" description: Hex-encoded Clarity value (optionally prefixed with 0x) diff --git a/docs/rpc/components/schemas/stackerdb-chunk-data.schema.yaml b/docs/rpc/components/schemas/stackerdb-chunk-data.schema.yaml index 248ec4c0373..6647a1eb794 100644 --- a/docs/rpc/components/schemas/stackerdb-chunk-data.schema.yaml +++ b/docs/rpc/components/schemas/stackerdb-chunk-data.schema.yaml @@ -1,5 +1,3 @@ -# TODO: Check if maximum is actually needed or not - type: object required: - slot_id diff --git a/docs/rpc/components/schemas/standard-principal.schema.yaml b/docs/rpc/components/schemas/standard-principal.schema.yaml index f9a9d52f0e8..a8057553376 100644 --- a/docs/rpc/components/schemas/standard-principal.schema.yaml +++ b/docs/rpc/components/schemas/standard-principal.schema.yaml @@ -6,3 +6,5 @@ maxLength: 41 examples: - SP31DA6FTSJX2WGTZ69SFY11BH51NZMB0ZW97B5P0 - ST000000000000000000002AMW42H + - SM2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKQVX8X0G + - SN2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKP6D2ZK9 diff --git a/docs/rpc/openapi.yaml b/docs/rpc/openapi.yaml index e219fdc8b06..4c4987c9e81 100644 --- a/docs/rpc/openapi.yaml +++ b/docs/rpc/openapi.yaml @@ -306,8 +306,8 @@ paths: application/json: schema: type: string - minLength: 4 - pattern: "^0x[0-9a-fA-F]+$" + minLength: 2 + pattern: "^(0x)?([0-9a-fA-F]{2})+$" description: Hex-encoded Clarity value (e.g. "0x0100000000000000000000000000000001") example: "0x0100000000000000000000000000000001" responses: @@ -700,7 +700,7 @@ paths: required: true description: | Stacks address of the trait-defining contract. - Must start with SP (mainnet) or ST (testnet). + Must start with SP/SM (mainnet) or ST/SN (testnet). schema: $ref: './components/schemas/standard-principal.schema.yaml' - name: trait_contract_name @@ -875,7 +875,7 @@ paths: block: type: string minLength: 400 - pattern: "^[0-9a-fA-F]+$" + pattern: "^([0-9a-fA-F]{2})+$" description: Hex-encoded block data (must be valid SIP-003 serialized block, typically 200+ bytes) chain_id: type: integer From 5b299a72e9962fb5c5f25e473af702af31c7d66a Mon Sep 17 00:00:00 2001 From: Simone Orsi <241460653+simone-stacks@users.noreply.github.com> Date: Tue, 16 Dec 2025 03:32:17 -0600 Subject: [PATCH 12/15] auto-generate permissive regexes, add structured 405 error type --- stackslib/src/net/api/getblock_v3.rs | 5 --- stackslib/src/net/api/gettenure.rs | 5 --- stackslib/src/net/http/error.rs | 21 ++++++++- stackslib/src/net/http/mod.rs | 9 ++++ stackslib/src/net/httpcore.rs | 67 ++++++++++++++++------------ 5 files changed, 67 insertions(+), 40 deletions(-) diff --git a/stackslib/src/net/api/getblock_v3.rs b/stackslib/src/net/api/getblock_v3.rs index cf6483af30a..dffb2c38f35 100644 --- a/stackslib/src/net/api/getblock_v3.rs +++ b/stackslib/src/net/api/getblock_v3.rs @@ -117,11 +117,6 @@ impl HttpRequest for RPCNakamotoBlockRequestHandler { Regex::new(r#"^/v3/blocks/(?P[0-9a-f]{64})$"#).unwrap() } - fn path_regex_permissive(&self) -> Regex { - // Permissive regex for 405 detection - matches any block_id format - Regex::new(r#"^/v3/blocks/[^/]+$"#).unwrap() - } - fn metrics_identifier(&self) -> &str { "/v3/blocks/:block_id" } diff --git a/stackslib/src/net/api/gettenure.rs b/stackslib/src/net/api/gettenure.rs index dc4570053e4..1f026d80ecc 100644 --- a/stackslib/src/net/api/gettenure.rs +++ b/stackslib/src/net/api/gettenure.rs @@ -146,11 +146,6 @@ impl HttpRequest for RPCNakamotoTenureRequestHandler { Regex::new(r#"^/v3/tenures/(?P[0-9a-f]{64})$"#).unwrap() } - fn path_regex_permissive(&self) -> Regex { - // Permissive regex for 405 detection - matches any block_id format - Regex::new(r#"^/v3/tenures/[^/]+$"#).unwrap() - } - fn metrics_identifier(&self) -> &str { "/v3/tenures/:block_id" } diff --git a/stackslib/src/net/http/error.rs b/stackslib/src/net/http/error.rs index cbc185e58ab..5b880e22646 100644 --- a/stackslib/src/net/http/error.rs +++ b/stackslib/src/net/http/error.rs @@ -297,11 +297,30 @@ impl HttpErrorResponse for HttpNotFound { /// HTTP 405 pub struct HttpMethodNotAllowed { error_text: String, + allowed_methods: Vec, } impl HttpMethodNotAllowed { pub fn new(error_text: String) -> Self { - Self { error_text } + Self { + error_text, + allowed_methods: vec![], + } + } + + pub fn with_allowed_methods(allowed_methods: Vec) -> Self { + let error_text = format!( + "Method Not Allowed. Allowed: {}", + allowed_methods.join(", ") + ); + Self { + error_text, + allowed_methods, + } + } + + pub fn get_allowed_methods(&self) -> &[String] { + &self.allowed_methods } } diff --git a/stackslib/src/net/http/mod.rs b/stackslib/src/net/http/mod.rs index 95697f294ac..39b50426af4 100644 --- a/stackslib/src/net/http/mod.rs +++ b/stackslib/src/net/http/mod.rs @@ -64,6 +64,8 @@ pub enum Error { UnderflowError(String), /// Http error response Http(u16, String), + /// Http 405 error response with the list of allowed methods (for Allow header) + HttpMethodNotAllowed(Vec), /// Application error AppError(String), } @@ -78,6 +80,9 @@ impl fmt::Display for Error { Error::ReadError(io_error) => fmt::Display::fmt(&io_error, f), Error::UnderflowError(msg) => write!(f, "{}", msg), Error::Http(code, msg) => write!(f, "code={}, msg={}", code, msg), + Error::HttpMethodNotAllowed(methods) => { + write!(f, "code=405, allowed={}", methods.join(", ")) + } Error::AppError(msg) => write!(f, "{}", &msg), } } @@ -93,6 +98,7 @@ impl std::error::Error for Error { Error::ReadError(io_error) => Some(io_error), Error::UnderflowError(_) => None, Error::Http(..) => None, + Error::HttpMethodNotAllowed(_) => None, Error::AppError(_) => None, } } @@ -138,6 +144,9 @@ impl Error { &x ))), Error::Http(code, msg) => http_error_from_code_and_text(code, msg), + Error::HttpMethodNotAllowed(methods) => { + Box::new(HttpMethodNotAllowed::with_allowed_methods(methods)) + } Error::AppError(x) => Box::new(HttpServerError::new(format!( "Unhandled application error: {:?}", &x diff --git a/stackslib/src/net/httpcore.rs b/stackslib/src/net/httpcore.rs index 634ac193711..1999dad54a7 100644 --- a/stackslib/src/net/httpcore.rs +++ b/stackslib/src/net/httpcore.rs @@ -56,9 +56,6 @@ use crate::net::{Error as NetError, MessageSequence, ProtocolFamily, StacksNodeS const CHUNK_BUF_LEN: usize = 32768; -/// Prefix for 405 error messages - used to extract allowed methods for the Allow header -const METHOD_NOT_ALLOWED_MSG_PREFIX: &str = "Method Not Allowed. Allowed: "; - /// canonical stacks tip height header pub const STACKS_HEADER_HEIGHT: &str = "X-Canonical-Stacks-Tip-Height"; @@ -74,6 +71,19 @@ pub const HTTP_REQUEST_ID_RESERVED: u32 = 0; /// The interval at which to send heartbeat logs const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(60); +/// Finds named path parameters like (?P...) in route patterns +static CAPTURE_GROUP_REGEX: std::sync::LazyLock = + std::sync::LazyLock::new(|| Regex::new(r"\(\?P<[^>]+>[^)]+\)").unwrap()); + +/// Loosens a strict route regex so any valid URL characters match the parameters. +/// This lets us tell apart 400 (bad params), 404 (no such route), and 405 (wrong method). +/// Complex patterns with nested parens fall back to strict matching (404 instead of 400). +fn make_permissive_regex(strict_regex: &Regex) -> Regex { + let pattern = strict_regex.as_str(); + let permissive = CAPTURE_GROUP_REGEX.replace_all(pattern, "[a-zA-Z0-9._~:@!$&'()*+,;=-]+"); + Regex::new(&permissive).unwrap_or_else(|_| strict_regex.clone()) +} + /// All representations of the `tip=` query parameter value #[derive(Debug, Clone, PartialEq)] pub enum TipRequest { @@ -1052,15 +1062,26 @@ impl StacksHttp { } } - /// Register an API RPC endpoint + /// Register an API RPC endpoint. + /// Auto-generates a permissive regex for 400/404/405 detection + /// unless the handler provides its own via path_regex_permissive(). pub fn register_rpc_endpoint( &mut self, handler: Handler, ) { + let strict_regex = handler.path_regex(); + let handler_permissive = handler.path_regex_permissive(); + + let permissive_regex = if handler_permissive.as_str() == strict_regex.as_str() { + make_permissive_regex(&strict_regex) + } else { + handler_permissive + }; + self.request_handlers.push(( handler.verb().to_string(), - handler.path_regex(), - handler.path_regex_permissive(), + strict_regex, + permissive_regex, Box::new(handler), )); } @@ -1197,16 +1218,11 @@ impl StacksHttp { if !any_strict_match && verb_matched_but_params_invalid { Err(NetError::Http(HttpError::Http( 400, - "Invalid path parameters".into(), + format!("Invalid path parameters for '{}'", &decoded_path), ))) } else if !allowed_methods.is_empty() { - Err(NetError::Http(HttpError::Http( - 405, - format!( - "{}{}", - METHOD_NOT_ALLOWED_MSG_PREFIX, - allowed_methods.join(", ") - ), + Err(NetError::Http(HttpError::HttpMethodNotAllowed( + allowed_methods, ))) } else { Err(NetError::Http(HttpError::Http( @@ -1293,13 +1309,11 @@ impl StacksHttp { let allowed_methods = self.find_allowed_methods(&decoded_path); if !allowed_methods.is_empty() { // Path exists but method is not allowed (405) - let allowed_str = allowed_methods.join(", "); + let error = HttpMethodNotAllowed::with_allowed_methods(allowed_methods); + let allowed_str = error.get_allowed_methods().join(", "); return StacksHttpResponse::new_error_with_headers( &request.preamble, - &HttpMethodNotAllowed::new(format!( - "{}{}", - METHOD_NOT_ALLOWED_MSG_PREFIX, &allowed_str - )), + &error, vec![("Allow".to_string(), allowed_str)], ) .try_into_contents(); @@ -1714,18 +1728,13 @@ impl ProtocolFamily for StacksHttp { Ok(data_request) => Ok((StacksHttpMessage::Request(data_request), len)), Err(NetError::Http(http_error)) => { // convert into a response - // for 405 responses, extract allowed methods and add Allow header - let extra_headers = if let HttpError::Http(405, ref msg) = &http_error { - if let Some(allowed_part) = - msg.strip_prefix(METHOD_NOT_ALLOWED_MSG_PREFIX) - { - vec![("Allow".to_string(), allowed_part.to_string())] + // for 405 responses, use the structured allowed methods + let extra_headers = + if let HttpError::HttpMethodNotAllowed(ref methods) = &http_error { + vec![("Allow".to_string(), methods.join(", "))] } else { vec![] - } - } else { - vec![] - }; + }; let resp = StacksHttpResponse::new_error_with_headers( http_request_preamble, &*http_error.into_http_error(), From 91c9ad5a05643a4b6dc976d4984167adae1ae577 Mon Sep 17 00:00:00 2001 From: Simone Orsi <241460653+simone-stacks@users.noreply.github.com> Date: Tue, 16 Dec 2025 08:28:25 -0600 Subject: [PATCH 13/15] update openapi removing 0x and adding 400 for some endpoints --- docs/rpc/openapi.yaml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/rpc/openapi.yaml b/docs/rpc/openapi.yaml index 4c4987c9e81..8eb9f2cf3a3 100644 --- a/docs/rpc/openapi.yaml +++ b/docs/rpc/openapi.yaml @@ -1303,7 +1303,7 @@ paths: description: Hex-encoded consensus hash (40 characters) schema: type: string - pattern: "^(0x)?[0-9a-f]{40}$" + pattern: "^[0-9a-f]{40}$" responses: "200": description: Sortition information for the consensus hash @@ -1337,7 +1337,7 @@ paths: description: Hex-encoded burn header hash (64 characters) schema: type: string - pattern: "^(0x)?[0-9a-f]{64}$" + pattern: "^[0-9a-f]{64}$" responses: "200": description: Sortition information for the burn header hash @@ -1457,6 +1457,8 @@ paths: $ref: "#/components/schemas/TransactionInfo" example: $ref: "./components/examples/get-transaction.example.json" + "400": + $ref: "#/components/responses/BadRequest" "404": $ref: "#/components/responses/NotFound" "500": @@ -1607,6 +1609,8 @@ paths: schema: type: string format: binary + "400": + $ref: "#/components/responses/BadRequest" "404": $ref: "#/components/responses/NotFound" "500": From 9764af8425ab73f954d4707070eb0643e883bbe5 Mon Sep 17 00:00:00 2001 From: Simone Orsi <241460653+simone-stacks@users.noreply.github.com> Date: Thu, 18 Dec 2025 22:14:56 +0100 Subject: [PATCH 14/15] update docs/rpc/openapi.yaml Co-authored-by: Radu Bahmata <92028479+BowTiedRadone@users.noreply.github.com> --- docs/rpc/openapi.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/rpc/openapi.yaml b/docs/rpc/openapi.yaml index 8eb9f2cf3a3..d0c71b62283 100644 --- a/docs/rpc/openapi.yaml +++ b/docs/rpc/openapi.yaml @@ -700,7 +700,6 @@ paths: required: true description: | Stacks address of the trait-defining contract. - Must start with SP/SM (mainnet) or ST/SN (testnet). schema: $ref: './components/schemas/standard-principal.schema.yaml' - name: trait_contract_name From 8126e8c01ed0559d08694044a92e2faba2e5facb Mon Sep 17 00:00:00 2001 From: Simone Orsi <241460653+simone-stacks@users.noreply.github.com> Date: Thu, 18 Dec 2025 22:15:05 +0100 Subject: [PATCH 15/15] update stackslib/src/net/httpcore.rs Co-authored-by: Radu Bahmata <92028479+BowTiedRadone@users.noreply.github.com> --- stackslib/src/net/httpcore.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/stackslib/src/net/httpcore.rs b/stackslib/src/net/httpcore.rs index 1999dad54a7..64dcedbe6e2 100644 --- a/stackslib/src/net/httpcore.rs +++ b/stackslib/src/net/httpcore.rs @@ -1070,12 +1070,12 @@ impl StacksHttp { handler: Handler, ) { let strict_regex = handler.path_regex(); - let handler_permissive = handler.path_regex_permissive(); + let permissive_regex = handler.path_regex_permissive(); - let permissive_regex = if handler_permissive.as_str() == strict_regex.as_str() { + let permissive_regex = if permissive_regex.as_str() == strict_regex.as_str() { make_permissive_regex(&strict_regex) } else { - handler_permissive + permissive_regex }; self.request_handlers.push((