Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions src/api/query-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
rafa-stacks marked this conversation as resolved.
Resolved
}

/**
* 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`).
Expand Down
2 changes: 2 additions & 0 deletions src/api/routes/v3/blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<never, never>,
Expand All @@ -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',
Expand Down
7 changes: 6 additions & 1 deletion src/api/routes/v3/principals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<never, never>,
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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: {
Expand Down
8 changes: 7 additions & 1 deletion src/api/routes/v3/transactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<
Expand Down Expand Up @@ -71,6 +74,7 @@ export const TransactionsRoutes: FastifyPluginAsync<
fastify.get(
'/transactions/batch',
{
onRequest: redirectUnprefixedHashParams({ query: ['tx_id'] }),
preHandler: handleChainTipCache,
preValidation: splitCommaSeparatedQueryParam('tx_id'),
schema: {
Expand Down Expand Up @@ -111,6 +115,7 @@ export const TransactionsRoutes: FastifyPluginAsync<
fastify.get(
'/transactions/:tx_id',
{
onRequest: redirectUnprefixedHashParams({ params: ['tx_id'] }),
preHandler: handleTransactionCache,
preValidation: splitCommaSeparatedQueryParam('include'),
schema: {
Expand Down Expand Up @@ -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',
Expand Down
139 changes: 139 additions & 0 deletions tests/api/transactions/v3-hash-redirects.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading