Skip to content

Commit 10117f5

Browse files
authored
fix: cap prevout lookup fan-out (#10)
1 parent 0a4114d commit 10117f5

3 files changed

Lines changed: 35 additions & 7 deletions

File tree

server/index.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,9 +111,12 @@ app.use(express.json({ limit: '64kb' }))
111111
// Apply the global rate limit to the API surface only (static assets are exempt).
112112
app.use('/api', globalLimiter)
113113

114-
// Stricter limits on the expensive search path, broadcast write path, and
115-
// public MCP endpoint, which can invoke daemon-backed wallet tools.
114+
// Stricter limits on the expensive RPC fan-out paths (search, transaction and
115+
// address lookups), the broadcast write path, and the public MCP endpoint, which
116+
// can invoke daemon-backed wallet tools.
116117
app.use('/api/search', strictLimiter)
118+
app.use('/api/transaction', strictLimiter)
119+
app.use('/api/address', strictLimiter)
117120
app.use('/api/tx/broadcast', strictLimiter)
118121

119122
// ---- API Routes ----

server/lib/cache.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,16 @@ export interface ResolvedPrevout {
279279
* Inputs beyond the cap are returned without a `prevout` and the frontend
280280
* degrades gracefully (it falls back to the unenriched display).
281281
*/
282-
const MAX_PREVOUT_LOOKUPS = 60
282+
const MAX_PREVOUT_LOOKUPS = 12
283+
const MAX_PREVOUT_LOOKUPS_PER_ADDRESS_PAGE = 40
284+
285+
interface PrevoutLookupBudget {
286+
remaining: number
287+
}
288+
289+
interface TransactionLookupOptions {
290+
prevoutLookupBudget?: PrevoutLookupBudget
291+
}
283292

284293
// Block-specific caching with different TTLs
285294
export class BlockCache extends BlockchainCache {
@@ -318,7 +327,14 @@ export class BlockCache extends BlockchainCache {
318327
* The cache is read/written directly here (rather than via {@link get}) because
319328
* the TTL must be chosen AFTER fetching, based on whether the tx is confirmed.
320329
*/
321-
async getTransaction(txid: string, network: NetworkType, verbose: boolean = true) {
330+
readonly maxPrevoutLookupsPerAddressPage = MAX_PREVOUT_LOOKUPS_PER_ADDRESS_PAGE
331+
332+
async getTransaction(
333+
txid: string,
334+
network: NetworkType,
335+
verbose: boolean = true,
336+
options: TransactionLookupOptions = {},
337+
) {
322338
assertValidNetwork(network)
323339

324340
// Non-verbose callers just want the raw hex string; it never carries
@@ -384,7 +400,7 @@ export class BlockCache extends BlockchainCache {
384400
}
385401

386402
const withConfirmations = await this.withLiveConfirmations(tx, network)
387-
return await this.enrichInputPrevouts(withConfirmations, network)
403+
return await this.enrichInputPrevouts(withConfirmations, network, options.prevoutLookupBudget)
388404
}
389405

390406
/**
@@ -410,6 +426,7 @@ export class BlockCache extends BlockchainCache {
410426
private async enrichInputPrevouts(
411427
tx: Record<string, unknown> | null,
412428
network: NetworkType,
429+
budget?: PrevoutLookupBudget,
413430
): Promise<Record<string, unknown> | null> {
414431
if (!tx || !Array.isArray(tx.vin)) {
415432
return tx
@@ -427,14 +444,19 @@ export class BlockCache extends BlockchainCache {
427444
const parentTxid = input.txid
428445
if (!parentTxid || parentTxid === ZERO_HASH || seen.has(parentTxid)) continue
429446
seen.add(parentTxid)
430-
if (parentTxids.length >= MAX_PREVOUT_LOOKUPS) break
447+
const aggregateRemaining = budget?.remaining ?? MAX_PREVOUT_LOOKUPS
448+
if (parentTxids.length >= MAX_PREVOUT_LOOKUPS || parentTxids.length >= aggregateRemaining) break
431449
parentTxids.push(parentTxid)
432450
}
433451

434452
if (parentTxids.length === 0) {
435453
return tx
436454
}
437455

456+
if (budget) {
457+
budget.remaining = Math.max(0, budget.remaining - parentTxids.length)
458+
}
459+
438460
// Fetch every needed parent transaction once, in parallel. A failed lookup
439461
// resolves to null and simply yields no prevout for the affected inputs.
440462
const parents = new Map<string, RawVout[] | null>()

server/routes/address.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,10 +209,13 @@ router.get("/:address/txs", async (req: Request, res: Response) => {
209209
interface TxVin { coinbase?: string; prevout?: { value?: number; addresses?: string[] } }
210210

211211
// Fetch transaction details and compute the address-relative net amount.
212+
// Share one prevout lookup budget across the page so a high `limit` cannot
213+
// multiply parent-transaction RPC/cache lookups without bound.
214+
const prevoutLookupBudget = { remaining: blockCache.maxPrevoutLookupsPerAddressPage };
212215
const transactions = [];
213216
for (const txid of pageTxids) {
214217
try {
215-
const tx = await blockCache.getTransaction(txid, network, true);
218+
const tx = await blockCache.getTransaction(txid, network, true, { prevoutLookupBudget });
216219
const vouts = (tx.vout ?? []) as TxVout[];
217220
const vins = (tx.vin ?? []) as TxVin[];
218221

0 commit comments

Comments
 (0)