Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
65 changes: 63 additions & 2 deletions yarn-project/p2p/src/services/reqresp/protocols/tx.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { MAX_TX_SIZE_KB } from '@aztec-labs/stdlib/p2p';
import { TxHash, TxHashArray } from '@aztec-labs/stdlib/tx';
import { describe, expect, it } from '@jest/globals';
import type { PeerId } from '@libp2p/interface';
import { mock, mockDeep } from 'jest-mock-extended';

import { calculateTxResponseSize } from './tx.js';
import type { MemPools } from '../../../mem_pools/interface.js';
import { ReqRespStatus, ReqRespStatusError } from '../status.js';
import { calculateTxResponseSize, reqRespTxHandler } from './tx.js';

describe('calculateTxResponseSize', () => {
it('should return correct size for a single tx hash', () => {
Expand All @@ -28,7 +32,7 @@ describe('calculateTxResponseSize', () => {

it('should fall back to single tx size for a raw TxHash buffer (not TxHashArray)', () => {
// A raw TxHash (32 bytes) is not a valid TxHashArray serialization.
// TxHashArray.fromBuffer silently returns empty array on parse failure.
// TxHashArray.fromBuffer returns an empty array (no error) on a parse failure.
const rawHash = TxHash.random().toBuffer();

expect(calculateTxResponseSize(rawHash)).toBe(MAX_TX_SIZE_KB + 1);
Expand All @@ -49,3 +53,60 @@ describe('calculateTxResponseSize', () => {
expect(calculateTxResponseSize(buffer)).toBe(MAX_TX_SIZE_KB + 1);
});
});

describe('reqRespTxHandler', () => {
const peerId = mock<PeerId>();

const makeMempools = (getTxByHash: (h: TxHash) => Promise<undefined>): MemPools => {
const mempools = mockDeep<MemPools>();
mempools.txPool.getTxByHash.mockImplementation(getTxByHash);
return mempools;
};

it('serves a repeated hash only once (de-duplicates before pool reads)', async () => {
const h = TxHash.random();
const lookups: string[] = [];
const mempools = makeMempools((x: TxHash) => {
lookups.push(x.toString());
return Promise.resolve(undefined);
});
const request = new TxHashArray(h, h, h, h, h).toBuffer();

await reqRespTxHandler(mempools)(peerId, request);

// One repeated hash must cause exactly one pool read, not one per copy.
expect(lookups).toEqual([h.toString()]);
});

it('still serves each distinct hash in a normal batch', async () => {
const hashes = Array.from({ length: 3 }, () => TxHash.random());
const lookups: string[] = [];
const mempools = makeMempools((x: TxHash) => {
lookups.push(x.toString());
return Promise.resolve(undefined);
});
const request = new TxHashArray(...hashes).toBuffer();

await reqRespTxHandler(mempools)(peerId, request);

expect(lookups.sort()).toEqual(hashes.map(h => h.toString()).sort());
});

it('rejects a request with too many hashes', async () => {
const lookups: string[] = [];
const mempools = makeMempools((x: TxHash) => {
lookups.push(x.toString());
return Promise.resolve(undefined);
});
const request = new TxHashArray(...Array.from({ length: 101 }, () => TxHash.random())).toBuffer();

const err = await reqRespTxHandler(mempools)(peerId, request).then(
() => undefined,
e => e,
);
expect(err).toBeInstanceOf(ReqRespStatusError);
expect((err as ReqRespStatusError).status).toBe(ReqRespStatus.BADLY_FORMED_REQUEST);
// Over-cap request is rejected before any pool read.
expect(lookups).toHaveLength(0);
});
});
24 changes: 23 additions & 1 deletion yarn-project/p2p/src/services/reqresp/protocols/tx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,17 @@ import { TxArray, TxHash, TxHashArray } from '@aztec-labs/stdlib/tx';
import type { PeerId } from '@libp2p/interface';

import type { MemPools } from '../../../mem_pools/interface.js';
import { DEFAULT_MAX_RESPONSE_SIZE_KB } from '../../encoding.js';
import type { ReqRespSubProtocolHandler } from '../interface.js';
import { ReqRespStatus, ReqRespStatusError } from '../status.js';

// Bound the request so the response the responder builds cannot exceed the reqresp
// transport's max response size: each hash yields up to MAX_TX_SIZE_KB, so cap the
// count at that budget. Honest requesters chunk at 8 (see chunkTxHashesRequest), well
// under this; a peer naming more is rejected before the pool lookup instead of forcing
// the node to read and serialize a response larger than the transport will carry.
const MAX_TX_HASHES_PER_REQUEST = Math.floor(DEFAULT_MAX_RESPONSE_SIZE_KB / MAX_TX_SIZE_KB);

/**
* We want to keep the logic of the req resp handler in this file, but we do not have a reference to the mempools here
* so we need to pass it in as a parameter.
Expand All @@ -30,9 +38,23 @@ export function reqRespTxHandler(mempools: MemPools): ReqRespSubProtocolHandler
throw new ReqRespStatusError(ReqRespStatus.BADLY_FORMED_REQUEST, { cause: err });
}

if (txHashes.length > MAX_TX_HASHES_PER_REQUEST) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Cap conflicts with configuration

If P2P_BATCH_TX_REQUESTER_TX_BATCH_SIZE is configured above 100, the requester sends a batch that this handler rejects as BADLY_FORMED_REQUEST. The requester does not split and retry that batch; it penalizes the peer and clears its transaction metadata, which can prevent the batch from being fetched. Constrain the configuration and chunking helper to this limit, or derive both sides from a shared constant.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This handler serves the TX subprotocol, whose requests are chunked by chunkTxHashesRequest (fixed default 8), not P2P_BATCH_TX_REQUESTER_TX_BATCH_SIZE — that config drives the batch requester, which only sends BLOCK_TXS (batch_tx_requester.ts sends ReqRespSubProtocol.BLOCK_TXS). So no configurable path reaches this handler above the cap of 100 (12x the fixed TX chunk). The equivalent config-vs-cap coupling is real for BLOCK_TXS and is handled in that finding's fix (A-2026).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right — I conflated the TX and BLOCK_TXS request paths. chunkTxHashesRequest uses the fixed default of 8 for this handler, while P2P_BATCH_TX_REQUESTER_TX_BATCH_SIZE applies to BLOCK_TXS, not TX. Therefore no configurable requester can exceed this handler's cap of 100, and the cap does not introduce the failure mode I flagged. The configuration-coupling concern belongs to the separate BLOCK_TXS fix; I'll withdraw this comment.

Tip: You can customize Greptile's behavior for this repo with .greptile/rules.md and .greptile/config.json.

throw new ReqRespStatusError(ReqRespStatus.BADLY_FORMED_REQUEST);
}

// De-duplicate before serving: without this a peer can repeat one hash many
// times and make the node re-read and re-serialize the same tx per copy,
// turning a small request into a much larger response.
const uniqueByHash = new Map<string, TxHash>();
for (const txHash of txHashes) {
uniqueByHash.set(txHash.toString(), txHash);
}

try {
const txs = new TxArray(
...(await Promise.all(txHashes.map(txHash => mempools.txPool.getTxByHash(txHash)))).filter(t => !!t),
...(await Promise.all([...uniqueByHash.values()].map(txHash => mempools.txPool.getTxByHash(txHash)))).filter(
t => !!t,
),
);
return txs.toBuffer();
} catch (err: any) {
Expand Down
Loading