Skip to content

Commit 28e9864

Browse files
authored
feat: add principal cache etag to account endpoints (#2097)
* feat: add principal cache * fix: also consider sponsor transactions * chore: import sponsors in migration * fix: add down migration
1 parent 06620c9 commit 28e9864

File tree

8 files changed

+236
-110
lines changed

8 files changed

+236
-110
lines changed
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/* eslint-disable camelcase */
2+
3+
exports.shorthands = undefined;
4+
5+
exports.up = pgm => {
6+
pgm.sql(`
7+
INSERT INTO principal_stx_txs
8+
(principal, tx_id, block_height, index_block_hash, microblock_hash, microblock_sequence,
9+
tx_index, canonical, microblock_canonical)
10+
(
11+
SELECT
12+
sponsor_address AS principal, tx_id, block_height, index_block_hash, microblock_hash,
13+
microblock_sequence, tx_index, canonical, microblock_canonical
14+
FROM txs
15+
WHERE sponsor_address IS NOT NULL
16+
)
17+
ON CONFLICT ON CONSTRAINT unique_principal_tx_id_index_block_hash_microblock_hash DO NOTHING
18+
`);
19+
};
20+
21+
exports.down = pgm => {};

src/api/controllers/cache-controller.ts

Lines changed: 26 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,13 @@ import * as prom from 'prom-client';
22
import { normalizeHashString } from '../../helpers';
33
import { PgStore } from '../../datastore/pg-store';
44
import { logger } from '../../logger';
5-
import { sha256 } from '@hirosystems/api-toolkit';
5+
import {
6+
CACHE_CONTROL_MUST_REVALIDATE,
7+
parseIfNoneMatchHeader,
8+
sha256,
9+
} from '@hirosystems/api-toolkit';
610
import { FastifyReply, FastifyRequest } from 'fastify';
711

8-
/**
9-
* A `Cache-Control` header used for re-validation based caching.
10-
* * `public` == allow proxies/CDNs to cache as opposed to only local browsers.
11-
* * `no-cache` == clients can cache a resource but should revalidate each time before using it.
12-
* * `must-revalidate` == somewhat redundant directive to assert that cache must be revalidated, required by some CDNs
13-
*/
14-
const CACHE_CONTROL_MUST_REVALIDATE = 'public, no-cache, must-revalidate';
15-
1612
/**
1713
* Describes a key-value to be saved into a request's locals, representing the current
1814
* state of the chain depending on the type of information being requested by the endpoint.
@@ -25,6 +21,8 @@ enum ETagType {
2521
mempool = 'mempool',
2622
/** ETag based on the status of a single transaction across the mempool or canonical chain. */
2723
transaction = 'transaction',
24+
/** Etag based on the confirmed balance of a single principal (STX address or contract id) */
25+
principal = 'principal',
2826
}
2927

3028
/** Value that means the ETag did get calculated but it is empty. */
@@ -75,52 +73,6 @@ function getETagMetrics(): ETagCacheMetrics {
7573
return _eTagMetrics;
7674
}
7775

78-
/**
79-
* Parses the etag values from a raw `If-None-Match` request header value.
80-
* The wrapping double quotes (if any) and validation prefix (if any) are stripped.
81-
* The parsing is permissive to account for commonly non-spec-compliant clients, proxies, CDNs, etc.
82-
* E.g. the value:
83-
* ```js
84-
* `"a", W/"b", c,d, "e", "f"`
85-
* ```
86-
* Would be parsed and returned as:
87-
* ```js
88-
* ['a', 'b', 'c', 'd', 'e', 'f']
89-
* ```
90-
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match#syntax
91-
* ```
92-
* If-None-Match: "etag_value"
93-
* If-None-Match: "etag_value", "etag_value", ...
94-
* If-None-Match: *
95-
* ```
96-
* @param ifNoneMatchHeaderValue - raw header value
97-
* @returns an array of etag values
98-
*/
99-
export function parseIfNoneMatchHeader(
100-
ifNoneMatchHeaderValue: string | undefined
101-
): string[] | undefined {
102-
if (!ifNoneMatchHeaderValue) {
103-
return undefined;
104-
}
105-
// Strip wrapping double quotes like `"hello"` and the ETag validation-prefix like `W/"hello"`.
106-
// The API returns compliant, strong-validation ETags (double quoted ASCII), but can't control what
107-
// clients, proxies, CDNs, etc may provide.
108-
const normalized = /^(?:"|W\/")?(.*?)"?$/gi.exec(ifNoneMatchHeaderValue.trim())?.[1];
109-
if (!normalized) {
110-
// This should never happen unless handling a buggy request with something like `If-None-Match: ""`,
111-
// or if there's a flaw in the above code. Log warning for now.
112-
logger.warn(`Normalized If-None-Match header is falsy: ${ifNoneMatchHeaderValue}`);
113-
return undefined;
114-
} else if (normalized.includes(',')) {
115-
// Multiple etag values provided, likely irrelevant extra values added by a proxy/CDN.
116-
// Split on comma, also stripping quotes, weak-validation prefixes, and extra whitespace.
117-
return normalized.split(/(?:W\/"|")?(?:\s*),(?:\s*)(?:W\/"|")?/gi);
118-
} else {
119-
// Single value provided (the typical case)
120-
return [normalized];
121-
}
122-
}
123-
12476
async function calculateETag(
12577
db: PgStore,
12678
etagType: ETagType,
@@ -155,7 +107,7 @@ async function calculateETag(
155107
}
156108
return digest.result.digest;
157109
} catch (error) {
158-
logger.error(error, 'Unable to calculate mempool');
110+
logger.error(error, 'Unable to calculate mempool etag');
159111
return;
160112
}
161113

@@ -178,7 +130,20 @@ async function calculateETag(
178130
];
179131
return sha256(elements.join(':'));
180132
} catch (error) {
181-
logger.error(error, 'Unable to calculate transaction');
133+
logger.error(error, 'Unable to calculate transaction etag');
134+
return;
135+
}
136+
137+
case ETagType.principal:
138+
try {
139+
const params = req.params as { address?: string; principal?: string };
140+
const principal = params.address ?? params.principal;
141+
if (!principal) return ETAG_EMPTY;
142+
const activity = await db.getPrincipalLastActivityTxIds(principal);
143+
const text = `${activity.stx_tx_id}:${activity.ft_tx_id}:${activity.nft_tx_id}`;
144+
return sha256(text);
145+
} catch (error) {
146+
logger.error(error, 'Unable to calculate principal etag');
182147
return;
183148
}
184149
}
@@ -224,3 +189,7 @@ export async function handleMempoolCache(request: FastifyRequest, reply: Fastify
224189
export async function handleTransactionCache(request: FastifyRequest, reply: FastifyReply) {
225190
return handleCache(ETagType.transaction, request, reply);
226191
}
192+
193+
export async function handlePrincipalCache(request: FastifyRequest, reply: FastifyReply) {
194+
return handleCache(ETagType.principal, request, reply);
195+
}

src/api/routes/address.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,12 @@ import {
1515
} from '../controllers/db-controller';
1616
import { InvalidRequestError, InvalidRequestErrorType, NotFoundError } from '../../errors';
1717
import { decodeClarityValueToRepr } from 'stacks-encoding-native-js';
18-
import { handleChainTipCache, handleMempoolCache } from '../controllers/cache-controller';
18+
import {
19+
handleChainTipCache,
20+
handleMempoolCache,
21+
handlePrincipalCache,
22+
handleTransactionCache,
23+
} from '../controllers/cache-controller';
1924
import { PgStore } from '../../datastore/pg-store';
2025
import { logger } from '../../logger';
2126
import { has0xPrefix } from '@hirosystems/api-toolkit';
@@ -86,7 +91,7 @@ export const AddressRoutes: FastifyPluginAsync<
8691
fastify.get(
8792
'/:principal/stx',
8893
{
89-
preHandler: handleChainTipCache,
94+
preHandler: handlePrincipalCache,
9095
schema: {
9196
operationId: 'get_account_stx_balance',
9297
summary: 'Get account STX balance',
@@ -142,7 +147,7 @@ export const AddressRoutes: FastifyPluginAsync<
142147
fastify.get(
143148
'/:principal/balances',
144149
{
145-
preHandler: handleChainTipCache,
150+
preHandler: handlePrincipalCache,
146151
schema: {
147152
operationId: 'get_account_balance',
148153
summary: 'Get account balances',
@@ -234,7 +239,7 @@ export const AddressRoutes: FastifyPluginAsync<
234239
fastify.get(
235240
'/:principal/transactions',
236241
{
237-
preHandler: handleChainTipCache,
242+
preHandler: handlePrincipalCache,
238243
schema: {
239244
deprecated: true,
240245
operationId: 'get_account_transactions',
@@ -307,7 +312,7 @@ export const AddressRoutes: FastifyPluginAsync<
307312
fastify.get(
308313
'/:principal/:tx_id/with_transfers',
309314
{
310-
preHandler: handleChainTipCache,
315+
preHandler: handleTransactionCache,
311316
schema: {
312317
deprecated: true,
313318
operationId: 'get_single_transaction_with_transfers',
@@ -373,7 +378,7 @@ export const AddressRoutes: FastifyPluginAsync<
373378
fastify.get(
374379
'/:principal/transactions_with_transfers',
375380
{
376-
preHandler: handleChainTipCache,
381+
preHandler: handlePrincipalCache,
377382
schema: {
378383
deprecated: true,
379384
operationId: 'get_account_transactions_with_transfers',
@@ -485,7 +490,7 @@ export const AddressRoutes: FastifyPluginAsync<
485490
fastify.get(
486491
'/:principal/assets',
487492
{
488-
preHandler: handleChainTipCache,
493+
preHandler: handlePrincipalCache,
489494
schema: {
490495
operationId: 'get_account_assets',
491496
summary: 'Get account assets',
@@ -533,7 +538,7 @@ export const AddressRoutes: FastifyPluginAsync<
533538
fastify.get(
534539
'/:principal/stx_inbound',
535540
{
536-
preHandler: handleChainTipCache,
541+
preHandler: handlePrincipalCache,
537542
schema: {
538543
operationId: 'get_account_inbound',
539544
summary: 'Get inbound STX transfers',

src/api/routes/v2/addresses.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import { handleChainTipCache } from '../../../api/controllers/cache-controller';
1+
import {
2+
handlePrincipalCache,
3+
handleTransactionCache,
4+
} from '../../../api/controllers/cache-controller';
25
import { AddressParamsSchema, AddressTransactionParamsSchema } from './schemas';
36
import { parseDbAddressTransactionTransfer, parseDbTxWithAccountTransferSummary } from './helpers';
47
import { InvalidRequestError, NotFoundError } from '../../../errors';
@@ -23,7 +26,7 @@ export const AddressRoutesV2: FastifyPluginAsync<
2326
fastify.get(
2427
'/:address/transactions',
2528
{
26-
preHandler: handleChainTipCache,
29+
preHandler: handlePrincipalCache,
2730
schema: {
2831
operationId: 'get_address_transactions',
2932
summary: 'Get address transactions',
@@ -71,7 +74,7 @@ export const AddressRoutesV2: FastifyPluginAsync<
7174
fastify.get(
7275
'/:address/transactions/:tx_id/events',
7376
{
74-
preHandler: handleChainTipCache,
77+
preHandler: handleTransactionCache,
7578
schema: {
7679
operationId: 'get_address_transaction_events',
7780
summary: 'Get events for an address transaction',

src/datastore/pg-store.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4404,4 +4404,44 @@ export class PgStore extends BasePgStore {
44044404
}
44054405
return result;
44064406
}
4407+
4408+
/** Retrieves the last transaction IDs with STX, FT and NFT activity for a principal */
4409+
async getPrincipalLastActivityTxIds(
4410+
principal: string
4411+
): Promise<{ stx_tx_id: string | null; ft_tx_id: string | null; nft_tx_id: string | null }> {
4412+
const result = await this.sql<
4413+
{ stx_tx_id: string | null; ft_tx_id: string | null; nft_tx_id: string | null }[]
4414+
>`
4415+
WITH last_stx AS (
4416+
SELECT tx_id
4417+
FROM principal_stx_txs
4418+
WHERE principal = ${principal} AND canonical = true AND microblock_canonical = true
4419+
ORDER BY block_height DESC, microblock_sequence DESC, tx_index DESC
4420+
LIMIT 1
4421+
),
4422+
last_ft AS (
4423+
SELECT tx_id
4424+
FROM ft_events
4425+
WHERE (sender = ${principal} OR recipient = ${principal})
4426+
AND canonical = true
4427+
AND microblock_canonical = true
4428+
ORDER BY block_height DESC, microblock_sequence DESC, tx_index DESC, event_index DESC
4429+
LIMIT 1
4430+
),
4431+
last_nft AS (
4432+
SELECT tx_id
4433+
FROM nft_events
4434+
WHERE (sender = ${principal} OR recipient = ${principal})
4435+
AND canonical = true
4436+
AND microblock_canonical = true
4437+
ORDER BY block_height DESC, microblock_sequence DESC, tx_index DESC, event_index DESC
4438+
LIMIT 1
4439+
)
4440+
SELECT
4441+
(SELECT tx_id FROM last_stx) AS stx_tx_id,
4442+
(SELECT tx_id FROM last_ft) AS ft_tx_id,
4443+
(SELECT tx_id FROM last_nft) AS nft_tx_id
4444+
`;
4445+
return result[0];
4446+
}
44074447
}

src/datastore/pg-write-store.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1180,6 +1180,7 @@ export class PgWriteStore extends PgStore {
11801180
tx.token_transfer_recipient_address,
11811181
tx.contract_call_contract_id,
11821182
tx.smart_contract_contract_id,
1183+
tx.sponsor_address,
11831184
].filter((p): p is string => !!p)
11841185
);
11851186
for (const event of stxEvents) {

src/event-replay/parquet-based/importers/new-block-importer.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,7 @@ const populateBatchInserters = (db: PgWriteStore) => {
251251
entry.tx.token_transfer_recipient_address,
252252
entry.tx.contract_call_contract_id,
253253
entry.tx.smart_contract_contract_id,
254+
entry.tx.sponsor_address,
254255
]
255256
.filter((p): p is string => !!p)
256257
.forEach(p => principals.add(p));

0 commit comments

Comments
 (0)