From 8297beec972ff7e190a10535b49605bacd49bcfa Mon Sep 17 00:00:00 2001 From: Rafa Cardenas <253999660+rafa-stacks@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:13:14 -0600 Subject: [PATCH 1/2] add sunset burn in mainnet --- src/api/routes/v3/staking-rewards.ts | 18 +++++++++++++++--- tests/api/burnchain/staking-rewards.test.ts | 15 ++++++++++++++- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/api/routes/v3/staking-rewards.ts b/src/api/routes/v3/staking-rewards.ts index 03ba67d886..d4229a7eef 100644 --- a/src/api/routes/v3/staking-rewards.ts +++ b/src/api/routes/v3/staking-rewards.ts @@ -1,9 +1,17 @@ import { FastifyPluginAsync } from 'fastify'; import { Server } from 'node:http'; import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; +import { STACKS_MAINNET } from '@stacks/network'; import { handleBurnchainChainTipCache } from '../../controllers/cache-controller.js'; import { StakingRewardsSchema } from '../../schemas/v3/entities/staking-rewards.js'; +/** + * BTC burned on mainnet by the 2.0-era PoX sunset-ramp surcharge (bitcoin blocks ~766050-781551), + * in satoshis. The node never reported these burns in its burn block events, so they are added as a + * fixed historical total. + */ +const MAINNET_SUNSET_BURN_AMOUNT = 489352064n; + export const StakingRewardsRoutes: FastifyPluginAsync< Record, Server, @@ -28,11 +36,15 @@ export const StakingRewardsRoutes: FastifyPluginAsync< }, async (_req, reply) => { const totals = await fastify.db.v3.getStakingRewards(); + const sunsetBurnAmount = + fastify.chainId === STACKS_MAINNET.chainId ? MAINNET_SUNSET_BURN_AMOUNT : 0n; + const rewardAmount = BigInt(totals.reward_amount); + const burnAmount = BigInt(totals.burn_amount) + sunsetBurnAmount; await reply.send({ btc: { - reward_amount: totals.reward_amount, - burn_amount: totals.burn_amount, - total_amount: (BigInt(totals.reward_amount) + BigInt(totals.burn_amount)).toString(), + reward_amount: rewardAmount.toString(), + burn_amount: burnAmount.toString(), + total_amount: (rewardAmount + burnAmount).toString(), }, }); } diff --git a/tests/api/burnchain/staking-rewards.test.ts b/tests/api/burnchain/staking-rewards.test.ts index 729758a2d5..79ecbd9d8e 100644 --- a/tests/api/burnchain/staking-rewards.test.ts +++ b/tests/api/burnchain/staking-rewards.test.ts @@ -1,7 +1,7 @@ import supertest from 'supertest'; import { afterEach, beforeEach, describe, test } from 'node:test'; import assert from 'node:assert/strict'; -import { STACKS_TESTNET } from '@stacks/network'; +import { STACKS_MAINNET, STACKS_TESTNET } from '@stacks/network'; import { ApiServer, startApiServer } from '../../../src/api/init.ts'; import { EventStreamServer, startEventServer } from '../../../src/event-stream/event-server.ts'; import { httpPostRequest } from '../../../src/helpers.ts'; @@ -182,6 +182,19 @@ describe('staking rewards totals', () => { }); }); + test('mainnet totals include the fixed PoX sunset burn', async () => { + await api.terminate(); + api = await startApiServer({ datastore: db, chainId: STACKS_MAINNET.chainId }); + assert.deepEqual(await getTotals(), { + btc: { reward_amount: '0', burn_amount: '489352064', total_amount: '489352064' }, + }); + + await deliverBurnBlock({ hash: '0xaa01', height: 100, burnAmount: 500n, rewardAmount: 1000n }); + assert.deepEqual(await getTotals(), { + btc: { reward_amount: '1000', burn_amount: '489352564', total_amount: '489353564' }, + }); + }); + test('migration backfill reproduces the live counters', async () => { const migrationName = '1779800000016_chain-tip-staking-rewards'; await deliverBurnBlock({ hash: '0xaa01', height: 100, burnAmount: 500n, rewardAmount: 1000n }); From 52346fe21bfc92a031fe41821438897f97adf759 Mon Sep 17 00:00:00 2001 From: Rafa Cardenas <253999660+rafa-stacks@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:20:22 -0600 Subject: [PATCH 2/2] block height --- src/api/routes/v3/staking-rewards.ts | 11 ++++-- src/datastore/v3/pg-store-v3.ts | 8 ++-- src/datastore/v3/types.ts | 2 + tests/api/burnchain/staking-rewards.test.ts | 42 +++++++++++++++++++-- 4 files changed, 53 insertions(+), 10 deletions(-) diff --git a/src/api/routes/v3/staking-rewards.ts b/src/api/routes/v3/staking-rewards.ts index d4229a7eef..f80c92d55e 100644 --- a/src/api/routes/v3/staking-rewards.ts +++ b/src/api/routes/v3/staking-rewards.ts @@ -6,11 +6,13 @@ import { handleBurnchainChainTipCache } from '../../controllers/cache-controller import { StakingRewardsSchema } from '../../schemas/v3/entities/staking-rewards.js'; /** - * BTC burned on mainnet by the 2.0-era PoX sunset-ramp surcharge (bitcoin blocks ~766050-781551), + * BTC burned on mainnet by the 2.0-era PoX sunset-ramp surcharge (bitcoin blocks ~766050-781550), * in satoshis. The node never reported these burns in its burn block events, so they are added as a - * fixed historical total. + * fixed historical total once the indexed burnchain has passed the sunset period. */ const MAINNET_SUNSET_BURN_AMOUNT = 489352064n; +/** Mainnet epoch 2.1 activation burn height, where the PoX sunset surcharge ended. */ +const MAINNET_SUNSET_END_BURN_HEIGHT = 781551; export const StakingRewardsRoutes: FastifyPluginAsync< Record, @@ -37,7 +39,10 @@ export const StakingRewardsRoutes: FastifyPluginAsync< async (_req, reply) => { const totals = await fastify.db.v3.getStakingRewards(); const sunsetBurnAmount = - fastify.chainId === STACKS_MAINNET.chainId ? MAINNET_SUNSET_BURN_AMOUNT : 0n; + fastify.chainId === STACKS_MAINNET.chainId && + totals.burn_block_height >= MAINNET_SUNSET_END_BURN_HEIGHT + ? MAINNET_SUNSET_BURN_AMOUNT + : 0n; const rewardAmount = BigInt(totals.reward_amount); const burnAmount = BigInt(totals.burn_amount) + sunsetBurnAmount; await reply.send({ diff --git a/src/datastore/v3/pg-store-v3.ts b/src/datastore/v3/pg-store-v3.ts index 87a97cc5cf..3c8a149e7e 100644 --- a/src/datastore/v3/pg-store-v3.ts +++ b/src/datastore/v3/pg-store-v3.ts @@ -2643,16 +2643,18 @@ export class PgStoreV3 extends BasePgStoreModule { /** * Gets the materialized network-wide staking reward totals. - * @returns The total sats generated by staking and burned by block commits. + * @returns The total sats generated by staking and burned by block commits, plus the highest + * ingested burn block height. */ async getStakingRewards(): Promise { const result = await this.sql` SELECT staking_reward_amount::text AS reward_amount, - staking_burn_amount::text AS burn_amount + staking_burn_amount::text AS burn_amount, + burn_block_height FROM chain_tip `; - return result[0] ?? { reward_amount: '0', burn_amount: '0' }; + return result[0] ?? { reward_amount: '0', burn_amount: '0', burn_block_height: 0 }; } /** diff --git a/src/datastore/v3/types.ts b/src/datastore/v3/types.ts index b1af2b05f4..22919b68c9 100644 --- a/src/datastore/v3/types.ts +++ b/src/datastore/v3/types.ts @@ -302,6 +302,8 @@ export interface DbStakingRewards { reward_amount: string; /** Total BTC burned by block commits, in satoshis. */ burn_amount: string; + /** Highest ingested burn block height. */ + burn_block_height: number; } export interface DbStakingSigner { diff --git a/tests/api/burnchain/staking-rewards.test.ts b/tests/api/burnchain/staking-rewards.test.ts index 79ecbd9d8e..b5bd3fb516 100644 --- a/tests/api/burnchain/staking-rewards.test.ts +++ b/tests/api/burnchain/staking-rewards.test.ts @@ -182,16 +182,50 @@ describe('staking rewards totals', () => { }); }); - test('mainnet totals include the fixed PoX sunset burn', async () => { + test('mainnet totals include the fixed PoX sunset burn once past the sunset period', async () => { await api.terminate(); api = await startApiServer({ datastore: db, chainId: STACKS_MAINNET.chainId }); assert.deepEqual(await getTotals(), { - btc: { reward_amount: '0', burn_amount: '489352064', total_amount: '489352064' }, + btc: { reward_amount: '0', burn_amount: '0', total_amount: '0' }, }); - await deliverBurnBlock({ hash: '0xaa01', height: 100, burnAmount: 500n, rewardAmount: 1000n }); + // Last burn block of the sunset period: the surcharge is not counted yet. + await deliverBurnBlock({ + hash: '0xaa01', + height: 781550, + burnAmount: 500n, + rewardAmount: 1000n, + }); + const beforeEnd = await supertest(api.server).get(`/extended/v3/staking/rewards`); + assert.deepEqual(JSON.parse(beforeEnd.text), { + btc: { reward_amount: '1000', burn_amount: '500', total_amount: '1500' }, + }); + + // Epoch 2.1 activation: the full surcharge is counted and the ETag invalidates. + await deliverBurnBlock({ + hash: '0xaa02', + height: 781551, + burnAmount: 250n, + rewardAmount: 2000n, + }); + const afterEnd = await supertest(api.server) + .get(`/extended/v3/staking/rewards`) + .set('If-None-Match', beforeEnd.headers['etag']); + assert.equal(afterEnd.status, 200); + assert.deepEqual(JSON.parse(afterEnd.text), { + btc: { reward_amount: '3000', burn_amount: '489352814', total_amount: '489355814' }, + }); + }); + + test('testnet totals never include the PoX sunset burn', async () => { + await deliverBurnBlock({ + hash: '0xaa01', + height: 781551, + burnAmount: 500n, + rewardAmount: 1000n, + }); assert.deepEqual(await getTotals(), { - btc: { reward_amount: '1000', burn_amount: '489352564', total_amount: '489353564' }, + btc: { reward_amount: '1000', burn_amount: '500', total_amount: '1500' }, }); });