Skip to content

Commit fb04c8d

Browse files
authored
fix: redirect v3 tx id and block hash params missing the 0x prefix (#2712)
1 parent 7479599 commit fb04c8d

5 files changed

Lines changed: 246 additions & 2 deletions

File tree

‎src/api/query-helpers.ts‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,78 @@ export function isValidTxId(tx_id: string) {
6767
}
6868
}
6969

70+
const UNPREFIXED_HASH_REGEX = /^[a-fA-F0-9]{64}$/;
71+
72+
/**
73+
* Prefixes a raw URL component holding a 32-byte hex hash with `0x` if it lacks one. The value is
74+
* percent-decoded before matching, since Fastify decodes it the same way before handing it to the
75+
* route (`%30` + 63 hex digits is a bare hash). Any other or malformed value is returned unchanged.
76+
*/
77+
function prefixUnprefixedHash(value: string): string {
78+
let decoded: string;
79+
try {
80+
decoded = decodeURIComponent(value);
81+
} catch {
82+
return value;
83+
}
84+
return UNPREFIXED_HASH_REGEX.test(decoded) ? `0x${decoded}` : value;
85+
}
86+
87+
/**
88+
* Builds an `onRequest` hook that answers with a `302` redirect to the same URL with `0x` added to
89+
* every 32-byte hash (tx id or block hash) supplied without it, so clients can pass hashes either
90+
* way while handlers, caches and the datastore only ever see the canonical prefixed form.
91+
*
92+
* Runs at `onRequest` so the redirect is issued before the ETag cache handlers and schema
93+
* validation. Querystring values are rewritten in both repeated and comma-separated forms.
94+
* @param opts - `params`: path parameter names that hold a hash (a value that is not a bare 64-char
95+
* hex string, e.g. a block height or `latest`, is left alone); `query`: querystring parameter
96+
* names that hold one or more hashes.
97+
* @returns An `onRequest` hook that redirects when any listed parameter needs the prefix.
98+
*/
99+
export function redirectUnprefixedHashParams(opts: { params?: string[]; query?: string[] }) {
100+
const pathParams = new Set((opts.params ?? []).map(p => `:${p}`));
101+
const queryParams = new Set(opts.query ?? []);
102+
return async (req: FastifyRequest, reply: FastifyReply) => {
103+
const searchIndex = req.url.indexOf('?');
104+
const path = searchIndex === -1 ? req.url : req.url.slice(0, searchIndex);
105+
const search = searchIndex === -1 ? '' : req.url.slice(searchIndex + 1);
106+
let changed = false;
107+
108+
// Route templates have no wildcards, so template segments line up with request segments.
109+
const templateSegments = req.routeOptions.url?.split('/') ?? [];
110+
const pathSegments = path.split('/').map((segment, i) => {
111+
if (!pathParams.has(templateSegments[i])) return segment;
112+
const prefixed = prefixUnprefixedHash(segment);
113+
if (prefixed !== segment) changed = true;
114+
return prefixed;
115+
});
116+
117+
const searchPairs = search
118+
? search.split('&').map(pair => {
119+
const eq = pair.indexOf('=');
120+
if (eq === -1) return pair;
121+
let key: string;
122+
try {
123+
key = decodeURIComponent(pair.slice(0, eq));
124+
} catch {
125+
return pair;
126+
}
127+
if (!queryParams.has(key)) return pair;
128+
const values = pair.slice(eq + 1).split(/,|%2C/i);
129+
const prefixed = values.map(prefixUnprefixedHash);
130+
if (prefixed.every((v, i) => v === values[i])) return pair;
131+
changed = true;
132+
return `${pair.slice(0, eq)}=${prefixed.join(',')}`;
133+
})
134+
: [];
135+
136+
if (!changed) return;
137+
const location = pathSegments.join('/') + (search ? `?${searchPairs.join('&')}` : '');
138+
return reply.redirect(location, 302);
139+
};
140+
}
141+
70142
/**
71143
* Builds a `preValidation` hook that normalizes an array-typed querystring parameter accepted in
72144
* two forms: repeated (`?tx_id=A&tx_id=B`) and comma-separated (`?tx_id=A,B`).

‎src/api/routes/v3/blocks.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
import { BlockHeightOrHashSchema } from '../../schemas/v3/entities/common.js';
1414
import { parseBlockParam } from '../v2/schemas.js';
1515
import { InvalidRequestError, NotFoundError } from '../../../errors.js';
16+
import { redirectUnprefixedHashParams } from '../../query-helpers.js';
1617

1718
export const BlocksRoutes: FastifyPluginAsync<
1819
Record<never, never>,
@@ -22,6 +23,7 @@ export const BlocksRoutes: FastifyPluginAsync<
2223
fastify.get(
2324
'/blocks/:height_or_hash/transactions',
2425
{
26+
onRequest: redirectUnprefixedHashParams({ params: ['height_or_hash'] }),
2527
preHandler: handleBlockCache,
2628
schema: {
2729
operationId: 'get_block_transactions',

‎src/api/routes/v3/principals.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,10 @@ import {
7171
import { PrincipalNoncesSchema } from '../../schemas/v3/entities/principal-nonces.js';
7272
import { MempoolTransactionSummarySchema } from '../../schemas/v3/entities/mempool-transaction-summaries.js';
7373
import { serializeDbMempoolTransactionSummary } from '../../serializers/v3/mempool-transactions.js';
74-
import { splitCommaSeparatedQueryParam } from '../../query-helpers.js';
74+
import {
75+
redirectUnprefixedHashParams,
76+
splitCommaSeparatedQueryParam,
77+
} from '../../query-helpers.js';
7578

7679
export const PrincipalsRoutes: FastifyPluginAsync<
7780
Record<never, never>,
@@ -260,6 +263,7 @@ export const PrincipalsRoutes: FastifyPluginAsync<
260263
fastify.get(
261264
'/principals/:principal/transactions/:tx_id/balance-changes',
262265
{
266+
onRequest: redirectUnprefixedHashParams({ params: ['tx_id'] }),
263267
preHandler: handleTransactionCache,
264268
schema: {
265269
operationId: 'get_principal_transaction_balance_changes',
@@ -303,6 +307,7 @@ export const PrincipalsRoutes: FastifyPluginAsync<
303307
fastify.get(
304308
'/principals/:principal/balance-changes',
305309
{
310+
onRequest: redirectUnprefixedHashParams({ query: ['tx_id'] }),
306311
preHandler: handlePrincipalCache,
307312
preValidation: splitCommaSeparatedQueryParam('tx_id'),
308313
schema: {

‎src/api/routes/v3/transactions.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,10 @@ import { MempoolTransactionSchema } from '../../schemas/v3/entities/mempool-tran
2323
import { NotFoundError } from '../../../errors.js';
2424
import { TransactionEventSchema } from '../../schemas/v3/entities/transaction-events.js';
2525
import { serializeDbTransactionEvent } from '../../serializers/v3/transaction-events.js';
26-
import { splitCommaSeparatedQueryParam } from '../../query-helpers.js';
26+
import {
27+
redirectUnprefixedHashParams,
28+
splitCommaSeparatedQueryParam,
29+
} from '../../query-helpers.js';
2730
import { TransactionIdsQuerystringParam } from '../../schemas/v3/params.js';
2831

2932
export const TransactionsRoutes: FastifyPluginAsync<
@@ -71,6 +74,7 @@ export const TransactionsRoutes: FastifyPluginAsync<
7174
fastify.get(
7275
'/transactions/batch',
7376
{
77+
onRequest: redirectUnprefixedHashParams({ query: ['tx_id'] }),
7478
preHandler: handleChainTipCache,
7579
preValidation: splitCommaSeparatedQueryParam('tx_id'),
7680
schema: {
@@ -111,6 +115,7 @@ export const TransactionsRoutes: FastifyPluginAsync<
111115
fastify.get(
112116
'/transactions/:tx_id',
113117
{
118+
onRequest: redirectUnprefixedHashParams({ params: ['tx_id'] }),
114119
preHandler: handleTransactionCache,
115120
preValidation: splitCommaSeparatedQueryParam('include'),
116121
schema: {
@@ -155,6 +160,7 @@ export const TransactionsRoutes: FastifyPluginAsync<
155160
fastify.get(
156161
'/transactions/:tx_id/events',
157162
{
163+
onRequest: redirectUnprefixedHashParams({ params: ['tx_id'] }),
158164
preHandler: handleTransactionCache,
159165
schema: {
160166
operationId: 'get_transaction_events',
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { describe, test, beforeEach, afterEach } from 'node:test';
2+
import * as assert from 'node:assert/strict';
3+
import { STACKS_TESTNET } from '@stacks/network';
4+
import { PgWriteStore } from '../../../src/datastore/pg-write-store.ts';
5+
import { ApiServer, startApiServer } from '../../../src/api/init.ts';
6+
import { migrate } from '../../test-helpers.ts';
7+
import { hex } from '../test-helpers.ts';
8+
import { TestBlockBuilder } from '../test-builders.ts';
9+
10+
/**
11+
* Every v3 endpoint that takes a tx id or block hash answers a hash supplied without its `0x`
12+
* prefix with a `302` to the same URL with the prefix added, keeping the rest of the querystring.
13+
*/
14+
15+
const PRINCIPAL = 'SP466FNC0P7JWTNM2R9T199QRZN1MYEDTAR0KP27';
16+
const TX_1 = hex(1);
17+
const TX_2 = hex(2);
18+
const BLOCK_HASH = hex(0xb10c);
19+
const bare = (hash: string) => hash.slice(2);
20+
21+
describe('v3 unprefixed hash redirects', () => {
22+
let db: PgWriteStore;
23+
let api: ApiServer;
24+
25+
const get = (url: string) => api.fastifyApp.inject({ method: 'GET', url });
26+
27+
beforeEach(async () => {
28+
await migrate('up');
29+
db = await PgWriteStore.connect({
30+
usageName: 'tests',
31+
withNotifier: false,
32+
skipMigrations: true,
33+
});
34+
api = await startApiServer({ datastore: db, chainId: STACKS_TESTNET.chainId });
35+
await db.update(
36+
new TestBlockBuilder({
37+
block_height: 1,
38+
block_hash: BLOCK_HASH,
39+
index_block_hash: BLOCK_HASH,
40+
})
41+
.addTx({ tx_id: TX_1, sender_address: PRINCIPAL })
42+
.build()
43+
);
44+
});
45+
46+
afterEach(async () => {
47+
await api.terminate();
48+
await db?.close();
49+
await migrate('down');
50+
});
51+
52+
const pathCases: [string, string, string][] = [
53+
['transaction', `/extended/v3/transactions/${bare(TX_1)}`, `/extended/v3/transactions/${TX_1}`],
54+
[
55+
'transaction with querystring',
56+
`/extended/v3/transactions/${bare(TX_1)}?include=post_conditions`,
57+
`/extended/v3/transactions/${TX_1}?include=post_conditions`,
58+
],
59+
[
60+
'transaction events',
61+
`/extended/v3/transactions/${bare(TX_1)}/events?limit=5`,
62+
`/extended/v3/transactions/${TX_1}/events?limit=5`,
63+
],
64+
[
65+
'principal transaction balance changes',
66+
`/extended/v3/principals/${PRINCIPAL}/transactions/${bare(TX_1)}/balance-changes`,
67+
`/extended/v3/principals/${PRINCIPAL}/transactions/${TX_1}/balance-changes`,
68+
],
69+
[
70+
'block transactions',
71+
`/extended/v3/blocks/${bare(BLOCK_HASH)}/transactions`,
72+
`/extended/v3/blocks/${BLOCK_HASH}/transactions`,
73+
],
74+
[
75+
'percent-encoded transaction',
76+
`/extended/v3/transactions/%30${bare(TX_1).slice(1)}`,
77+
`/extended/v3/transactions/${TX_1}`,
78+
],
79+
[
80+
'percent-encoded block hash',
81+
`/extended/v3/blocks/%30${bare(BLOCK_HASH).slice(1)}/transactions`,
82+
`/extended/v3/blocks/${BLOCK_HASH}/transactions`,
83+
],
84+
];
85+
86+
for (const [name, url, location] of pathCases) {
87+
test(`redirects ${name} path param`, async () => {
88+
const res = await get(url);
89+
assert.equal(res.statusCode, 302);
90+
assert.equal(res.headers.location, location);
91+
});
92+
}
93+
94+
const queryCases: [string, string, string][] = [
95+
[
96+
'batch, repeated',
97+
`/extended/v3/transactions/batch?tx_id=${bare(TX_1)}&tx_id=${TX_2}`,
98+
`/extended/v3/transactions/batch?tx_id=${TX_1}&tx_id=${TX_2}`,
99+
],
100+
[
101+
'batch, comma-separated',
102+
`/extended/v3/transactions/batch?tx_id=${TX_1},${bare(TX_2)}`,
103+
`/extended/v3/transactions/batch?tx_id=${TX_1},${TX_2}`,
104+
],
105+
[
106+
'batch, encoded comma',
107+
`/extended/v3/transactions/batch?tx_id=${bare(TX_1)}%2C${bare(TX_2)}`,
108+
`/extended/v3/transactions/batch?tx_id=${TX_1},${TX_2}`,
109+
],
110+
[
111+
'principal balance changes, other params kept in place',
112+
`/extended/v3/principals/${PRINCIPAL}/balance-changes?limit=5&tx_id=${bare(TX_1)}`,
113+
`/extended/v3/principals/${PRINCIPAL}/balance-changes?limit=5&tx_id=${TX_1}`,
114+
],
115+
[
116+
'batch, percent-encoded',
117+
`/extended/v3/transactions/batch?tx_id=%30${bare(TX_1).slice(1)},${TX_2}`,
118+
`/extended/v3/transactions/batch?tx_id=${TX_1},${TX_2}`,
119+
],
120+
[
121+
'batch, valueless and malformed params kept verbatim',
122+
`/extended/v3/transactions/batch?flag&%E0=1&tx_id=${bare(TX_1)}`,
123+
`/extended/v3/transactions/batch?flag&%E0=1&tx_id=${TX_1}`,
124+
],
125+
];
126+
127+
for (const [name, url, location] of queryCases) {
128+
test(`redirects ${name} query param`, async () => {
129+
const res = await get(url);
130+
assert.equal(res.statusCode, 302);
131+
assert.equal(res.headers.location, location);
132+
});
133+
}
134+
135+
test('does not redirect prefixed hashes, heights or aliases', async () => {
136+
for (const url of [
137+
`/extended/v3/transactions/${TX_1}`,
138+
`/extended/v3/transactions/batch?tx_id=${TX_1}`,
139+
`/extended/v3/blocks/${BLOCK_HASH}/transactions`,
140+
`/extended/v3/blocks/1/transactions`,
141+
`/extended/v3/blocks/latest/transactions`,
142+
]) {
143+
const res = await get(url);
144+
assert.equal(res.statusCode, 200, url);
145+
}
146+
});
147+
148+
test('leaves malformed ids to schema validation', async () => {
149+
const res = await get(`/extended/v3/transactions/${bare(TX_1).slice(1)}`);
150+
assert.equal(res.statusCode, 400);
151+
});
152+
153+
test('redirect target resolves the transaction', async () => {
154+
const redirect = await get(`/extended/v3/transactions/${bare(TX_1)}`);
155+
const res = await get(redirect.headers.location as string);
156+
assert.equal(res.statusCode, 200);
157+
assert.equal(JSON.parse(res.body).tx_id, TX_1);
158+
});
159+
});

0 commit comments

Comments
 (0)