From 079763132f10592041fcff19e27fd22e7459dfb8 Mon Sep 17 00:00:00 2001 From: Rafa Cardenas <253999660+rafa-stacks@users.noreply.github.com> Date: Thu, 8 Oct 2026 10:00:20 -0600 Subject: [PATCH 1/2] prefix redirects --- src/api/query-helpers.ts | 62 ++++++++ src/api/routes/v3/blocks.ts | 2 + src/api/routes/v3/principals.ts | 7 +- src/api/routes/v3/transactions.ts | 8 +- .../transactions/v3-hash-redirects.test.ts | 139 ++++++++++++++++++ 5 files changed, 216 insertions(+), 2 deletions(-) create mode 100644 tests/api/transactions/v3-hash-redirects.test.ts diff --git a/src/api/query-helpers.ts b/src/api/query-helpers.ts index 43293871d8..8c2231b75b 100644 --- a/src/api/query-helpers.ts +++ b/src/api/query-helpers.ts @@ -67,6 +67,68 @@ export function isValidTxId(tx_id: string) { } } +const UNPREFIXED_HASH_REGEX = /^[a-fA-F0-9]{64}$/; + +/** Prefixes a 32-byte hex hash with `0x` if it lacks one; any other value is returned unchanged. */ +function prefixUnprefixedHash(value: string): string { + return UNPREFIXED_HASH_REGEX.test(value) ? `0x${value}` : value; +} + +/** + * Builds an `onRequest` hook that answers with a `302` redirect to the same URL with `0x` added to + * every 32-byte hash (tx id or block hash) supplied without it, so clients can pass hashes either + * way while handlers, caches and the datastore only ever see the canonical prefixed form. + * + * Runs at `onRequest` so the redirect is issued before the ETag cache handlers and schema + * validation. Querystring values are rewritten in both repeated and comma-separated forms. + * @param opts - `params`: path parameter names that hold a hash (a value that is not a bare 64-char + * hex string, e.g. a block height or `latest`, is left alone); `query`: querystring parameter + * names that hold one or more hashes. + * @returns An `onRequest` hook that redirects when any listed parameter needs the prefix. + */ +export function redirectUnprefixedHashParams(opts: { params?: string[]; query?: string[] }) { + const pathParams = new Set((opts.params ?? []).map(p => `:${p}`)); + const queryParams = new Set(opts.query ?? []); + return async (req: FastifyRequest, reply: FastifyReply) => { + const searchIndex = req.url.indexOf('?'); + const path = searchIndex === -1 ? req.url : req.url.slice(0, searchIndex); + const search = searchIndex === -1 ? '' : req.url.slice(searchIndex + 1); + let changed = false; + + // Route templates have no wildcards, so template segments line up with request segments. + const templateSegments = req.routeOptions.url?.split('/') ?? []; + const pathSegments = path.split('/').map((segment, i) => { + if (!pathParams.has(templateSegments[i])) return segment; + const prefixed = prefixUnprefixedHash(segment); + if (prefixed !== segment) changed = true; + return prefixed; + }); + + const searchPairs = search + ? search.split('&').map(pair => { + const eq = pair.indexOf('='); + if (eq === -1) return pair; + let key: string; + try { + key = decodeURIComponent(pair.slice(0, eq)); + } catch { + return pair; + } + if (!queryParams.has(key)) return pair; + const values = pair.slice(eq + 1).split(/,|%2C/i); + const prefixed = values.map(prefixUnprefixedHash); + if (prefixed.every((v, i) => v === values[i])) return pair; + changed = true; + return `${pair.slice(0, eq)}=${prefixed.join(',')}`; + }) + : []; + + if (!changed) return; + const location = pathSegments.join('/') + (search ? `?${searchPairs.join('&')}` : ''); + return reply.redirect(location, 302); + }; +} + /** * Builds a `preValidation` hook that normalizes an array-typed querystring parameter accepted in * two forms: repeated (`?tx_id=A&tx_id=B`) and comma-separated (`?tx_id=A,B`). diff --git a/src/api/routes/v3/blocks.ts b/src/api/routes/v3/blocks.ts index 3635c0604e..5218dd9c28 100644 --- a/src/api/routes/v3/blocks.ts +++ b/src/api/routes/v3/blocks.ts @@ -13,6 +13,7 @@ import { import { BlockHeightOrHashSchema } from '../../schemas/v3/entities/common.js'; import { parseBlockParam } from '../v2/schemas.js'; import { InvalidRequestError, NotFoundError } from '../../../errors.js'; +import { redirectUnprefixedHashParams } from '../../query-helpers.js'; export const BlocksRoutes: FastifyPluginAsync< Record, @@ -22,6 +23,7 @@ export const BlocksRoutes: FastifyPluginAsync< fastify.get( '/blocks/:height_or_hash/transactions', { + onRequest: redirectUnprefixedHashParams({ params: ['height_or_hash'] }), preHandler: handleBlockCache, schema: { operationId: 'get_block_transactions', diff --git a/src/api/routes/v3/principals.ts b/src/api/routes/v3/principals.ts index 7466396645..feb5ce60fb 100644 --- a/src/api/routes/v3/principals.ts +++ b/src/api/routes/v3/principals.ts @@ -71,7 +71,10 @@ import { import { PrincipalNoncesSchema } from '../../schemas/v3/entities/principal-nonces.js'; import { MempoolTransactionSummarySchema } from '../../schemas/v3/entities/mempool-transaction-summaries.js'; import { serializeDbMempoolTransactionSummary } from '../../serializers/v3/mempool-transactions.js'; -import { splitCommaSeparatedQueryParam } from '../../query-helpers.js'; +import { + redirectUnprefixedHashParams, + splitCommaSeparatedQueryParam, +} from '../../query-helpers.js'; export const PrincipalsRoutes: FastifyPluginAsync< Record, @@ -260,6 +263,7 @@ export const PrincipalsRoutes: FastifyPluginAsync< fastify.get( '/principals/:principal/transactions/:tx_id/balance-changes', { + onRequest: redirectUnprefixedHashParams({ params: ['tx_id'] }), preHandler: handleTransactionCache, schema: { operationId: 'get_principal_transaction_balance_changes', @@ -303,6 +307,7 @@ export const PrincipalsRoutes: FastifyPluginAsync< fastify.get( '/principals/:principal/balance-changes', { + onRequest: redirectUnprefixedHashParams({ query: ['tx_id'] }), preHandler: handlePrincipalCache, preValidation: splitCommaSeparatedQueryParam('tx_id'), schema: { diff --git a/src/api/routes/v3/transactions.ts b/src/api/routes/v3/transactions.ts index 270264dde9..c30a9487e5 100644 --- a/src/api/routes/v3/transactions.ts +++ b/src/api/routes/v3/transactions.ts @@ -23,7 +23,10 @@ import { MempoolTransactionSchema } from '../../schemas/v3/entities/mempool-tran import { NotFoundError } from '../../../errors.js'; import { TransactionEventSchema } from '../../schemas/v3/entities/transaction-events.js'; import { serializeDbTransactionEvent } from '../../serializers/v3/transaction-events.js'; -import { splitCommaSeparatedQueryParam } from '../../query-helpers.js'; +import { + redirectUnprefixedHashParams, + splitCommaSeparatedQueryParam, +} from '../../query-helpers.js'; import { TransactionIdsQuerystringParam } from '../../schemas/v3/params.js'; export const TransactionsRoutes: FastifyPluginAsync< @@ -71,6 +74,7 @@ export const TransactionsRoutes: FastifyPluginAsync< fastify.get( '/transactions/batch', { + onRequest: redirectUnprefixedHashParams({ query: ['tx_id'] }), preHandler: handleChainTipCache, preValidation: splitCommaSeparatedQueryParam('tx_id'), schema: { @@ -111,6 +115,7 @@ export const TransactionsRoutes: FastifyPluginAsync< fastify.get( '/transactions/:tx_id', { + onRequest: redirectUnprefixedHashParams({ params: ['tx_id'] }), preHandler: handleTransactionCache, preValidation: splitCommaSeparatedQueryParam('include'), schema: { @@ -155,6 +160,7 @@ export const TransactionsRoutes: FastifyPluginAsync< fastify.get( '/transactions/:tx_id/events', { + onRequest: redirectUnprefixedHashParams({ params: ['tx_id'] }), preHandler: handleTransactionCache, schema: { operationId: 'get_transaction_events', diff --git a/tests/api/transactions/v3-hash-redirects.test.ts b/tests/api/transactions/v3-hash-redirects.test.ts new file mode 100644 index 0000000000..92eab6a1e2 --- /dev/null +++ b/tests/api/transactions/v3-hash-redirects.test.ts @@ -0,0 +1,139 @@ +import { describe, test, beforeEach, afterEach } from 'node:test'; +import * as assert from 'node:assert/strict'; +import { STACKS_TESTNET } from '@stacks/network'; +import { PgWriteStore } from '../../../src/datastore/pg-write-store.ts'; +import { ApiServer, startApiServer } from '../../../src/api/init.ts'; +import { migrate } from '../../test-helpers.ts'; +import { hex } from '../test-helpers.ts'; +import { TestBlockBuilder } from '../test-builders.ts'; + +/** + * Every v3 endpoint that takes a tx id or block hash answers a hash supplied without its `0x` + * prefix with a `302` to the same URL with the prefix added, keeping the rest of the querystring. + */ + +const PRINCIPAL = 'SP466FNC0P7JWTNM2R9T199QRZN1MYEDTAR0KP27'; +const TX_1 = hex(1); +const TX_2 = hex(2); +const BLOCK_HASH = hex(0xb10c); +const bare = (hash: string) => hash.slice(2); + +describe('v3 unprefixed hash redirects', () => { + let db: PgWriteStore; + let api: ApiServer; + + const get = (url: string) => api.fastifyApp.inject({ method: 'GET', url }); + + beforeEach(async () => { + await migrate('up'); + db = await PgWriteStore.connect({ + usageName: 'tests', + withNotifier: false, + skipMigrations: true, + }); + api = await startApiServer({ datastore: db, chainId: STACKS_TESTNET.chainId }); + await db.update( + new TestBlockBuilder({ + block_height: 1, + block_hash: BLOCK_HASH, + index_block_hash: BLOCK_HASH, + }) + .addTx({ tx_id: TX_1, sender_address: PRINCIPAL }) + .build() + ); + }); + + afterEach(async () => { + await api.terminate(); + await db?.close(); + await migrate('down'); + }); + + const pathCases: [string, string, string][] = [ + ['transaction', `/extended/v3/transactions/${bare(TX_1)}`, `/extended/v3/transactions/${TX_1}`], + [ + 'transaction with querystring', + `/extended/v3/transactions/${bare(TX_1)}?include=post_conditions`, + `/extended/v3/transactions/${TX_1}?include=post_conditions`, + ], + [ + 'transaction events', + `/extended/v3/transactions/${bare(TX_1)}/events?limit=5`, + `/extended/v3/transactions/${TX_1}/events?limit=5`, + ], + [ + 'principal transaction balance changes', + `/extended/v3/principals/${PRINCIPAL}/transactions/${bare(TX_1)}/balance-changes`, + `/extended/v3/principals/${PRINCIPAL}/transactions/${TX_1}/balance-changes`, + ], + [ + 'block transactions', + `/extended/v3/blocks/${bare(BLOCK_HASH)}/transactions`, + `/extended/v3/blocks/${BLOCK_HASH}/transactions`, + ], + ]; + + for (const [name, url, location] of pathCases) { + test(`redirects ${name} path param`, async () => { + const res = await get(url); + assert.equal(res.statusCode, 302); + assert.equal(res.headers.location, location); + }); + } + + const queryCases: [string, string, string][] = [ + [ + 'batch, repeated', + `/extended/v3/transactions/batch?tx_id=${bare(TX_1)}&tx_id=${TX_2}`, + `/extended/v3/transactions/batch?tx_id=${TX_1}&tx_id=${TX_2}`, + ], + [ + 'batch, comma-separated', + `/extended/v3/transactions/batch?tx_id=${TX_1},${bare(TX_2)}`, + `/extended/v3/transactions/batch?tx_id=${TX_1},${TX_2}`, + ], + [ + 'batch, encoded comma', + `/extended/v3/transactions/batch?tx_id=${bare(TX_1)}%2C${bare(TX_2)}`, + `/extended/v3/transactions/batch?tx_id=${TX_1},${TX_2}`, + ], + [ + 'principal balance changes, other params kept in place', + `/extended/v3/principals/${PRINCIPAL}/balance-changes?limit=5&tx_id=${bare(TX_1)}`, + `/extended/v3/principals/${PRINCIPAL}/balance-changes?limit=5&tx_id=${TX_1}`, + ], + ]; + + for (const [name, url, location] of queryCases) { + test(`redirects ${name} query param`, async () => { + const res = await get(url); + assert.equal(res.statusCode, 302); + assert.equal(res.headers.location, location); + }); + } + + test('does not redirect prefixed hashes, heights or aliases', async () => { + for (const url of [ + `/extended/v3/transactions/${TX_1}`, + `/extended/v3/transactions/batch?tx_id=${TX_1}`, + `/extended/v3/blocks/${BLOCK_HASH}/transactions`, + `/extended/v3/blocks/1/transactions`, + `/extended/v3/blocks/latest/transactions`, + ]) { + const res = await get(url); + assert.equal(res.statusCode, 200, url); + } + }); + + test('leaves malformed ids to schema validation', async () => { + const res = await get(`/extended/v3/transactions/${bare(TX_1).slice(1)}`); + assert.equal(res.statusCode, 400); + }); + + test('redirect target resolves the transaction', async () => { + const redirect = await get(`/extended/v3/transactions/${bare(TX_1)}`); + const res = await get(redirect.headers.location as string); + assert.equal(res.statusCode, 200); + assert.equal(JSON.parse(res.body).tx_id, TX_1); + }); +}); From 6065b9288a429e21ec8258b0df0570ed705b6649 Mon Sep 17 00:00:00 2001 From: Rafa Cardenas <253999660+rafa-stacks@users.noreply.github.com> Date: Thu, 8 Oct 2026 11:09:55 -0600 Subject: [PATCH 2/2] encoded prefix --- src/api/query-helpers.ts | 14 +++++++++++-- .../transactions/v3-hash-redirects.test.ts | 20 +++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/api/query-helpers.ts b/src/api/query-helpers.ts index 8c2231b75b..d60b777c24 100644 --- a/src/api/query-helpers.ts +++ b/src/api/query-helpers.ts @@ -69,9 +69,19 @@ export function isValidTxId(tx_id: string) { const UNPREFIXED_HASH_REGEX = /^[a-fA-F0-9]{64}$/; -/** Prefixes a 32-byte hex hash with `0x` if it lacks one; any other value is returned unchanged. */ +/** + * Prefixes a raw URL component holding a 32-byte hex hash with `0x` if it lacks one. The value is + * percent-decoded before matching, since Fastify decodes it the same way before handing it to the + * route (`%30` + 63 hex digits is a bare hash). Any other or malformed value is returned unchanged. + */ function prefixUnprefixedHash(value: string): string { - return UNPREFIXED_HASH_REGEX.test(value) ? `0x${value}` : value; + let decoded: string; + try { + decoded = decodeURIComponent(value); + } catch { + return value; + } + return UNPREFIXED_HASH_REGEX.test(decoded) ? `0x${decoded}` : value; } /** diff --git a/tests/api/transactions/v3-hash-redirects.test.ts b/tests/api/transactions/v3-hash-redirects.test.ts index 92eab6a1e2..13a85a7fee 100644 --- a/tests/api/transactions/v3-hash-redirects.test.ts +++ b/tests/api/transactions/v3-hash-redirects.test.ts @@ -71,6 +71,16 @@ describe('v3 unprefixed hash redirects', () => { `/extended/v3/blocks/${bare(BLOCK_HASH)}/transactions`, `/extended/v3/blocks/${BLOCK_HASH}/transactions`, ], + [ + 'percent-encoded transaction', + `/extended/v3/transactions/%30${bare(TX_1).slice(1)}`, + `/extended/v3/transactions/${TX_1}`, + ], + [ + 'percent-encoded block hash', + `/extended/v3/blocks/%30${bare(BLOCK_HASH).slice(1)}/transactions`, + `/extended/v3/blocks/${BLOCK_HASH}/transactions`, + ], ]; for (const [name, url, location] of pathCases) { @@ -102,6 +112,16 @@ describe('v3 unprefixed hash redirects', () => { `/extended/v3/principals/${PRINCIPAL}/balance-changes?limit=5&tx_id=${bare(TX_1)}`, `/extended/v3/principals/${PRINCIPAL}/balance-changes?limit=5&tx_id=${TX_1}`, ], + [ + 'batch, percent-encoded', + `/extended/v3/transactions/batch?tx_id=%30${bare(TX_1).slice(1)},${TX_2}`, + `/extended/v3/transactions/batch?tx_id=${TX_1},${TX_2}`, + ], + [ + 'batch, valueless and malformed params kept verbatim', + `/extended/v3/transactions/batch?flag&%E0=1&tx_id=${bare(TX_1)}`, + `/extended/v3/transactions/batch?flag&%E0=1&tx_id=${TX_1}`, + ], ]; for (const [name, url, location] of queryCases) {