Skip to content
Merged
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
25 changes: 25 additions & 0 deletions migrations/1779800000027_miner-rewards-recipient-maturity-index.ts
Original file line number Diff line number Diff line change
@@ -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',
});
}
9 changes: 7 additions & 2 deletions src/api/controllers/cache-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(':')
);
}
}
Expand Down
49 changes: 37 additions & 12 deletions src/datastore/pg-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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];
}
Expand Down
131 changes: 131 additions & 0 deletions tests/api/cache/cache-control.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading