diff --git a/migrations/1779800000027_miner-rewards-recipient-maturity-index.ts b/migrations/1779800000027_miner-rewards-recipient-maturity-index.ts new file mode 100644 index 0000000000..b64b068319 --- /dev/null +++ b/migrations/1779800000027_miner-rewards-recipient-maturity-index.ts @@ -0,0 +1,25 @@ +import type { ColumnDefinitions, MigrationBuilder } from 'node-pg-migrate'; + +export const shorthands: ColumnDefinitions | undefined = undefined; + +/** + * Serves the principal ETag's "latest canonical matured miner reward" lookup (`WHERE recipient = ? + * AND canonical = true ORDER BY mature_block_height DESC LIMIT 1`) as a single index probe. The + * existing `recipient` index would have to read and sort every reward an address ever received, + * which for an active miner is tens of thousands of rows on every cached principal request. + * + * `miner_rewards` only gets a handful of rows per block, so the extra index adds negligible + * write-path cost, and the canonical-only predicate keeps orphaned rewards out of it. + */ +export function up(pgm: MigrationBuilder): void { + pgm.createIndex('miner_rewards', ['recipient', { name: 'mature_block_height', sort: 'DESC' }], { + name: 'miner_rewards_recipient_mature_block_height_canonical_index', + where: 'canonical = true', + }); +} + +export function down(pgm: MigrationBuilder): void { + pgm.dropIndex('miner_rewards', [], { + name: 'miner_rewards_recipient_mature_block_height_canonical_index', + }); +} diff --git a/src/api/controllers/cache-controller.ts b/src/api/controllers/cache-controller.ts index bb7f92c12b..f2b76db4b3 100644 --- a/src/api/controllers/cache-controller.ts +++ b/src/api/controllers/cache-controller.ts @@ -183,9 +183,14 @@ async function calculateETag( principal, etagType == ETagType.principalMempool ); - if (!activity.confirmed && !activity.mempool) return ETAG_EMPTY; + if (!activity.confirmed && !activity.mempool && !activity.miner_reward) return ETAG_EMPTY; return sha256( - `${activity.confirmed ?? ''}:${activity.mempool ?? ''}:${activity.pox_state ?? ''}` + [ + activity.confirmed ?? '', + activity.mempool ?? '', + activity.pox_state ?? '', + activity.miner_reward ?? '', + ].join(':') ); } } diff --git a/src/datastore/pg-store.ts b/src/datastore/pg-store.ts index c6ad0c6419..623d4bdece 100644 --- a/src/datastore/pg-store.ts +++ b/src/datastore/pg-store.ts @@ -4381,25 +4381,43 @@ export class PgStore extends BasePgStore { /** * Retrieves the last transaction IDs with STX, FT or NFT activity for a principal, with or - * without mempool transactions. Also returns the current PoX lock state so that ETags - * invalidate when STX unlock without a transaction being emitted, which happens both at a - * natural PoX cycle boundary and at a PoX version's forced-unlock height (e.g. the - * `pox_v4_unlock_height` set when a hard fork activates a new PoX contract). + * without mempool transactions. Also returns the current PoX lock state so that ETags invalidate + * when STX unlock without a transaction being emitted, which happens both at a natural PoX cycle + * boundary and at a PoX version's forced-unlock height (e.g. the `pox_v4_unlock_height` set when + * a hard fork activates a new PoX contract). * - * The lock state is derived from the same source the balance responses use for current-tip - * reads — the materialized `stx_locked_balances` table resolved against the `pox_state` - * forced-unlock heights (mirroring {@link resolveMaterializedStxLock}) — so the ETag can never - * report `locked` while the response body reports an unlocked balance. This covers every PoX - * version, including pox-5, whose locks are materialized from synthetic stake events. + * The lock state is derived from the same source the balance responses use for current-tip reads + * — the materialized `stx_locked_balances` table resolved against the `pox_state` forced-unlock + * heights (mirroring {@link resolveMaterializedStxLock}) — so the ETag can never report `locked` + * while the response body reports an unlocked balance. This covers every PoX version, including + * pox-5, whose locks are materialized from synthetic stake events. + * + * It also returns the latest canonical matured miner reward credited to the principal. Rewards + * change STX balances without a transaction (they're credited when they mature and debited again + * if their maturing block is re-orged out), so they need their own ETag component. The node only + * reports rewards once matured, so every canonical row is matured at the tip; the index block + * hash identifies the block they matured in, so both a new reward and a re-org that orphans or + * replaces the latest one change the value. * @param includeMempool - include mempool transactions * @returns the last confirmed and mempool transaction IDs for the principal, plus PoX lock state + * and the latest canonical matured miner reward */ async getPrincipalLastActivityTxIds( principal: string, includeMempool: boolean = false - ): Promise<{ confirmed: string | null; mempool: string | null; pox_state: string | null }> { + ): Promise<{ + confirmed: string | null; + mempool: string | null; + pox_state: string | null; + miner_reward: string | null; + }> { const result = await this.sql< - { confirmed: string | null; mempool: string | null; pox_state: string | null }[] + { + confirmed: string | null; + mempool: string | null; + pox_state: string | null; + miner_reward: string | null; + }[] >` SELECT ( SELECT '0x' || encode(tx_id, 'hex') AS tx_id @@ -4464,7 +4482,14 @@ export class PgStore extends BasePgStore { ) ps ON TRUE WHERE slb.principal = ${principal} ) lock - ) AS pox_state + ) AS pox_state, + ( + SELECT mature_block_height::text || ':' || encode(index_block_hash, 'hex') + FROM miner_rewards + WHERE recipient = ${principal} AND canonical = true + ORDER BY mature_block_height DESC + LIMIT 1 + ) AS miner_reward `; return result[0]; } diff --git a/tests/api/cache/cache-control.test.ts b/tests/api/cache/cache-control.test.ts index 1162512a8a..c11b1407f3 100644 --- a/tests/api/cache/cache-control.test.ts +++ b/tests/api/cache/cache-control.test.ts @@ -1192,4 +1192,135 @@ describe('cache-control tests', () => { .set('If-None-Match', unlockedMempoolEtag); assert.equal(cachedMempool.status, 304); }); + + test('principal cache control invalidates on matured miner rewards and their re-org', async () => { + const miner = 'ST28D4Q6RCQSJ6F7TEYWQDS4N1RXYEP9YBWMYSB97'; + const url = `/extended/v3/principals/${miner}/balances/stx`; + const getBalance = async (etag?: string) => { + const req = supertest(api.server).get(url); + return etag ? req.set('If-None-Match', etag) : req; + }; + + // Block 1: the miner receives STX, so it has confirmed activity and an ETag. + await db.update( + new TestBlockBuilder({ + block_height: 1, + index_block_hash: '0x01', + parent_index_block_hash: '0x00', + }) + .addTx({ tx_id: '0x0001', token_transfer_recipient_address: miner }) + .addTxStxEvent({ recipient: miner, amount: 5000n }) + .build() + ); + const initial = await getBalance(); + assert.equal(initial.status, 200); + assert.equal(JSON.parse(initial.text).balance, '5000'); + const initialEtag = initial.headers['etag']; + assert.ok(initialEtag); + assert.equal((await getBalance(initialEtag)).status, 304); + + // Block 2: a miner reward matures. No transaction touches the miner, but its balance changes. + await db.update( + new TestBlockBuilder({ + block_height: 2, + index_block_hash: '0x02', + parent_index_block_hash: '0x01', + }) + .addMinerReward({ + recipient: miner, + coinbase_amount: 1000n, + tx_fees_anchored: 0n, + tx_fees_streamed_confirmed: 0n, + tx_fees_streamed_produced: 0n, + }) + .build() + ); + const rewarded = await getBalance(initialEtag); + assert.equal(rewarded.status, 200); + assert.equal(JSON.parse(rewarded.text).balance, '6000'); + const rewardedEtag = rewarded.headers['etag']; + assert.ok(rewardedEtag); + assert.notEqual(rewardedEtag, initialEtag); + + // Unchanged state: the new ETag is a cache hit, even as the chain advances without rewards. + assert.equal((await getBalance(rewardedEtag)).status, 304); + await db.update( + new TestBlockBuilder({ + block_height: 3, + index_block_hash: '0x03', + parent_index_block_hash: '0x02', + }).build() + ); + assert.equal((await getBalance(rewardedEtag)).status, 304); + + // Re-org: a longer fork from block 1 orphans block 2, rolling the reward back. + await db.update( + new TestBlockBuilder({ + block_height: 2, + index_block_hash: '0x02bb', + parent_index_block_hash: '0x01', + }).build() + ); + await db.update( + new TestBlockBuilder({ + block_height: 3, + index_block_hash: '0x03bb', + parent_index_block_hash: '0x02bb', + }).build() + ); + await db.update( + new TestBlockBuilder({ + block_height: 4, + index_block_hash: '0x04bb', + parent_index_block_hash: '0x03bb', + }).build() + ); + const rolledBack = await getBalance(rewardedEtag); + assert.equal(rolledBack.status, 200); + assert.equal(JSON.parse(rolledBack.text).balance, '5000'); + const rolledBackEtag = rolledBack.headers['etag']; + assert.ok(rolledBackEtag); + assert.notEqual(rolledBackEtag, rewardedEtag); + assert.equal((await getBalance(rolledBackEtag)).status, 304); + }); + + test('principal cache control sets an ETag for reward-only principals', async () => { + const miner = 'ST28D4Q6RCQSJ6F7TEYWQDS4N1RXYEP9YBWMYSB97'; + const url = `/extended/v3/principals/${miner}/balances/stx`; + + await db.update( + new TestBlockBuilder({ + block_height: 1, + index_block_hash: '0x01', + parent_index_block_hash: '0x00', + }).build() + ); + // No activity yet: no ETag. + const empty = await supertest(api.server).get(url); + assert.equal(empty.status, 200); + assert.equal(empty.headers['etag'], undefined); + + await db.update( + new TestBlockBuilder({ + block_height: 2, + index_block_hash: '0x02', + parent_index_block_hash: '0x01', + }) + .addMinerReward({ + recipient: miner, + coinbase_amount: 1000n, + tx_fees_anchored: 0n, + tx_fees_streamed_confirmed: 0n, + tx_fees_streamed_produced: 0n, + }) + .build() + ); + const rewarded = await supertest(api.server).get(url); + assert.equal(rewarded.status, 200); + assert.equal(JSON.parse(rewarded.text).balance, '1000'); + const etag = rewarded.headers['etag']; + assert.ok(etag); + const cached = await supertest(api.server).get(url).set('If-None-Match', etag); + assert.equal(cached.status, 304); + }); });