diff --git a/migrations/1779742831642_principal-bond-positions.ts b/migrations/1779742831642_principal-bond-positions.ts index 2d92f4ddd..eb89b26d6 100644 --- a/migrations/1779742831642_principal-bond-positions.ts +++ b/migrations/1779742831642_principal-bond-positions.ts @@ -87,6 +87,14 @@ export function up(pgm: MigrationBuilder): void { type: 'numeric', notNull: true, }, + // Running sBTC reward sats accrued to this position, distributed from the + // bond's per-sat reward rate by this participant's staked weight. Maintained + // incrementally on write (and via signed deltas on reorg). + accrued_rewards: { + type: 'numeric', + notNull: true, + default: 0, + }, }); pgm.createIndex('principal_bond_positions', ['principal', 'bond_index'], { diff --git a/migrations/1779745340752_bond-reward-distributions.ts b/migrations/1779745340752_bond-reward-distributions.ts index 3a74e398b..53b7ecd27 100644 --- a/migrations/1779745340752_bond-reward-distributions.ts +++ b/migrations/1779745340752_bond-reward-distributions.ts @@ -59,34 +59,32 @@ export function up(pgm: MigrationBuilder): void { type: 'boolean', notNull: true, }, - remaining_rewards: { + // Per-bond reward distribution, from the pox-5 `bond-distribution` event + // (emitted once per bond during a `calculate-rewards` call). + target_yield: { type: 'numeric', notNull: true, }, - accrued_rewards: { + // sBTC rewards earned by this bond in this calculation. + bond_rewards: { type: 'numeric', notNull: true, }, - new_reserve: { + // Total sats staked in the bond at the time of this calculation. + bond_staked_sats: { type: 'numeric', notNull: true, }, - stx_staker_rewards: { + // Per-sat rewards accrued in this calculation. + accrued_rewards_per_sat: { type: 'numeric', notNull: true, }, - stx_cycle: { - type: 'integer', - notNull: true, - }, - cycle_staked_ustx: { + // Running per-sat reward total for the bond after this calculation. + cumulative_rewards_per_sat: { type: 'numeric', notNull: true, }, - next_rewards_per_ustx: { - type: 'numeric', - notNull: true, - } }); } diff --git a/migrations/1779745340753_bond-reward-calculations.ts b/migrations/1779745340753_bond-reward-calculations.ts new file mode 100644 index 000000000..0d5b868c0 --- /dev/null +++ b/migrations/1779745340753_bond-reward-calculations.ts @@ -0,0 +1,123 @@ +import type { ColumnDefinitions, MigrationBuilder } from 'node-pg-migrate'; + +export const shorthands: ColumnDefinitions | undefined = undefined; + +/** + * Cycle-level aggregate emitted once per pox-5 `calculate-rewards` call (the + * `calculate-rewards` event), after all per-bond `bond-distribution` events have + * been folded and the STX reward-cycle accounting committed. Per-bond reward + * data lives in `bond_reward_distributions`. + */ +export function up(pgm: MigrationBuilder): void { + pgm.createTable('bond_reward_calculations', { + tx_id: { + type: 'bytea', + notNull: true, + }, + tx_index: { + type: 'smallint', + notNull: true, + }, + block_height: { + type: 'integer', + notNull: true, + }, + block_hash: { + type: 'bytea', + }, + block_time: { + type: 'bigint', + }, + index_block_hash: { + type: 'bytea', + notNull: true, + }, + parent_block_hash: { + type: 'bytea', + }, + parent_index_block_hash: { + type: 'bytea', + notNull: true, + }, + burn_block_height: { + type: 'integer', + }, + burn_block_time: { + type: 'bigint', + }, + microblock_hash: { + type: 'bytea', + notNull: true, + }, + microblock_sequence: { + type: 'integer', + notNull: true, + }, + microblock_canonical: { + type: 'boolean', + notNull: true, + }, + canonical: { + type: 'boolean', + notNull: true, + }, + // Burn height at which rewards were calculated. + calculation_height: { + type: 'integer', + notNull: true, + }, + // Total new rewards accrued since the last calculation. + gross_accrued_rewards: { + type: 'numeric', + notNull: true, + }, + // Portion of gross_accrued_rewards paid out to bonds. + total_bond_rewards: { + type: 'numeric', + notNull: true, + }, + // Amount added to the reserve this calculation. + reserve_deposit: { + type: 'numeric', + notNull: true, + }, + // Reserve balance after reserve_deposit was applied. + reserve_balance: { + type: 'numeric', + notNull: true, + }, + // STX reward cycle this calculation accounts for. + stx_cycle: { + type: 'integer', + notNull: true, + }, + // Rewards allocated to STX stakers for the cycle. + total_stx_staker_rewards: { + type: 'numeric', + notNull: true, + }, + // Total uSTX staked for the cycle. + cycle_staked_ustx: { + type: 'numeric', + notNull: true, + }, + // Per-uSTX rewards accrued this calculation (zero when no STX is staked). + accrued_rewards_per_ustx: { + type: 'numeric', + notNull: true, + }, + // Running per-uSTX reward total for the cycle after this calculation. + cumulative_rewards_per_ustx: { + type: 'numeric', + notNull: true, + }, + }); + + pgm.createIndex('bond_reward_calculations', 'tx_id'); + pgm.createIndex('bond_reward_calculations', ['index_block_hash', 'canonical']); + pgm.createIndex('bond_reward_calculations', 'stx_cycle'); +} + +export function down(pgm: MigrationBuilder): void { + pgm.dropTable('bond_reward_calculations'); +} diff --git a/migrations/1779745340754_principal-bond-reward-distributions.ts b/migrations/1779745340754_principal-bond-reward-distributions.ts new file mode 100644 index 000000000..9464bc2e0 --- /dev/null +++ b/migrations/1779745340754_principal-bond-reward-distributions.ts @@ -0,0 +1,92 @@ +import type { ColumnDefinitions, MigrationBuilder } from 'node-pg-migrate'; + +export const shorthands: ColumnDefinitions | undefined = undefined; + +/** + * Per-participant reward distribution source rows. Each pox-5 `bond-distribution` + * event is split across the bond's participants by their staked weight; one row + * is written here per participant per distribution (the amount that participant + * accrued). These rows are the source of truth for reorg adjustments to the + * running `principal_bond_positions.accrued_rewards` total — analogous to how + * `ft_events` rows back the running `ft_balances` totals. + */ +export function up(pgm: MigrationBuilder): void { + pgm.createTable('principal_bond_reward_distributions', { + id: { + type: 'bigserial', + primaryKey: true, + }, + principal: { + type: 'text', + notNull: true, + }, + bond_index: { + type: 'integer', + notNull: true, + }, + // sBTC reward sats this participant accrued from this distribution. + reward_amount: { + type: 'numeric', + notNull: true, + }, + tx_id: { + type: 'bytea', + notNull: true, + }, + tx_index: { + type: 'smallint', + notNull: true, + }, + block_height: { + type: 'integer', + notNull: true, + }, + block_hash: { + type: 'bytea', + }, + block_time: { + type: 'bigint', + }, + index_block_hash: { + type: 'bytea', + notNull: true, + }, + parent_block_hash: { + type: 'bytea', + }, + parent_index_block_hash: { + type: 'bytea', + notNull: true, + }, + burn_block_height: { + type: 'integer', + }, + burn_block_time: { + type: 'bigint', + }, + microblock_hash: { + type: 'bytea', + notNull: true, + }, + microblock_sequence: { + type: 'integer', + notNull: true, + }, + microblock_canonical: { + type: 'boolean', + notNull: true, + }, + canonical: { + type: 'boolean', + notNull: true, + }, + }); + + pgm.createIndex('principal_bond_reward_distributions', 'tx_id'); + pgm.createIndex('principal_bond_reward_distributions', ['index_block_hash', 'canonical']); + pgm.createIndex('principal_bond_reward_distributions', ['principal', 'bond_index']); +} + +export function down(pgm: MigrationBuilder): void { + pgm.dropTable('principal_bond_reward_distributions'); +} diff --git a/package-lock.json b/package-lock.json index e58519543..05c8fd6c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "@fastify/type-provider-typebox": "5.2.0", "@sinclair/typebox": "0.34.48", "@stacks/api-toolkit": "1.13.0", - "@stacks/codec": "2.0.0-pox5.1", + "@stacks/codec": "2.0.0-pox5.2", "@stacks/common": "7.3.1", "@stacks/encryption": "7.4.0", "@stacks/network": "7.3.1", @@ -1699,9 +1699,9 @@ "license": "MIT" }, "node_modules/@stacks/codec": { - "version": "2.0.0-pox5.1", - "resolved": "https://registry.npmjs.org/@stacks/codec/-/codec-2.0.0-pox5.1.tgz", - "integrity": "sha512-EVQ8+BR70GGy7HSaVq4Z1YQdgZods8FwFqwRgUcDAi2mO2DYUty6OUDgrWaQ+/AfTNaAzGAKqsFK0dhBIp+zaA==", + "version": "2.0.0-pox5.2", + "resolved": "https://registry.npmjs.org/@stacks/codec/-/codec-2.0.0-pox5.2.tgz", + "integrity": "sha512-nFztqavC/Gsv0FAsS74GJalK7WNmfslEmiHhwzCpaW8St9Nwd25bxz2VJ4LwYEo7V9WMvNr41k29CUn3Y7XnSQ==", "license": "GPL-3.0", "dependencies": { "@types/node": "^24.0.0", diff --git a/package.json b/package.json index ab311d0a6..bcbeb8e3e 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ "@fastify/type-provider-typebox": "5.2.0", "@sinclair/typebox": "0.34.48", "@stacks/api-toolkit": "1.13.0", - "@stacks/codec": "2.0.0-pox5.1", + "@stacks/codec": "2.0.0-pox5.2", "@stacks/common": "7.3.1", "@stacks/encryption": "7.4.0", "@stacks/network": "7.3.1", diff --git a/src/api/init.ts b/src/api/init.ts index 818794784..4ca429290 100644 --- a/src/api/init.ts +++ b/src/api/init.ts @@ -54,7 +54,6 @@ import { TransactionsRoutes } from './routes/v3/transactions.js'; import { MempoolRoutes } from './routes/v3/mempool.js'; import { BlocksRoutes } from './routes/v3/blocks.js'; import { StakingBondsRoutes } from './routes/v3/staking-bonds.js'; -import { StakingPrincipalsRoutes } from './routes/v3/staking-principals.js'; export interface ApiServer { fastifyApp: FastifyInstance; @@ -111,7 +110,6 @@ export const StacksApiRoutes: FastifyPluginAsync< await fastify.register(MempoolRoutes); await fastify.register(PrincipalsRoutes); await fastify.register(StakingBondsRoutes); - await fastify.register(StakingPrincipalsRoutes); await fastify.register(TransactionsRoutes); }, { prefix: '/extended/v3' } diff --git a/src/api/routes/v3/principals.ts b/src/api/routes/v3/principals.ts index 8cc412533..807c8a81c 100644 --- a/src/api/routes/v3/principals.ts +++ b/src/api/routes/v3/principals.ts @@ -11,6 +11,9 @@ import { } from '../../schemas/v3/cursors.js'; import { PrincipalTransactionSummarySchema } from '../../schemas/v3/entities/principal-transactions.js'; import { serializePrincipalTransactionSummary } from '../../serializers/v3/transactions.js'; +import { PrincipalBondPositionSchema } from '../../schemas/v3/entities/principal-bond-positions.js'; +import { serializeDbPrincipalBondPosition } from '../../serializers/v3/bonds.js'; +import { handleChainTipCache } from '../../controllers/cache-controller.js'; export const PrincipalsRoutes: FastifyPluginAsync< Record, @@ -59,15 +62,24 @@ export const PrincipalsRoutes: FastifyPluginAsync< fastify.get( '/principals/:principal/balances/staking', { + preHandler: handleChainTipCache, schema: { operationId: 'get_principal_staking_balances', summary: 'Get principal staking balances', - description: 'Get principal staking balances', + description: + "Get a principal's staking balances: its bond positions (staked amounts and accrued rewards) across all bonds it is enrolled in", tags: ['Staking'], + params: Type.Object({ principal: PrincipalSchema }), + response: { + 200: Type.Array(PrincipalBondPositionSchema), + }, }, }, - async (_req, reply) => { - await reply.send(); + async (req, reply) => { + const positions = await fastify.db.v3.getPrincipalStakingPositions({ + principal: req.params.principal, + }); + await reply.send(positions.map(serializeDbPrincipalBondPosition)); } ); diff --git a/src/api/routes/v3/staking-principals.ts b/src/api/routes/v3/staking-principals.ts deleted file mode 100644 index de1f6e42e..000000000 --- a/src/api/routes/v3/staking-principals.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { FastifyPluginAsync } from 'fastify'; -import { Server } from 'node:http'; -import { Type, TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; -import { handleChainTipCache } from '../../controllers/cache-controller.js'; -import { PrincipalSchema } from '../../schemas/v3/entities/common.js'; -import { PrincipalBondPositionSchema } from '../../schemas/v3/entities/principal-bond-positions.js'; -import { serializeDbPrincipalBondPosition } from '../../serializers/v3/bonds.js'; - -export const StakingPrincipalsRoutes: FastifyPluginAsync< - Record, - Server, - TypeBoxTypeProvider -> = async fastify => { - fastify.get( - '/staking/principals/:principal/positions', - { - preHandler: handleChainTipCache, - schema: { - operationId: 'get_principal_staking_positions', - summary: 'Get principal staking positions', - description: "Get a principal's bond positions across all bonds it is enrolled in", - tags: ['Staking'], - params: Type.Object({ - principal: PrincipalSchema, - }), - response: { - 200: Type.Array(PrincipalBondPositionSchema), - }, - }, - }, - async (req, reply) => { - const positions = await fastify.db.v3.getPrincipalStakingPositions({ - principal: req.params.principal, - }); - await reply.send(positions.map(serializeDbPrincipalBondPosition)); - } - ); - - await Promise.resolve(); -}; diff --git a/src/api/schemas/v3/entities/principal-bond-positions.ts b/src/api/schemas/v3/entities/principal-bond-positions.ts index c27e77514..b0106bff8 100644 --- a/src/api/schemas/v3/entities/principal-bond-positions.ts +++ b/src/api/schemas/v3/entities/principal-bond-positions.ts @@ -28,5 +28,8 @@ export const PrincipalBondPositionSchema = Type.Object({ amount: Type.String({ description: 'The amount of STX that is locked up for this principal', }), + accrued_rewards: Type.String({ + description: "The sBTC reward sats accrued to this participant's position", + }), }); export type PrincipalBondPosition = Static; diff --git a/src/api/serializers/v3/bonds.ts b/src/api/serializers/v3/bonds.ts index 033773939..0a1cd2a90 100644 --- a/src/api/serializers/v3/bonds.ts +++ b/src/api/serializers/v3/bonds.ts @@ -140,6 +140,7 @@ export function serializeDbPrincipalBondPosition( }, }, amount: position.stx_locked, + accrued_rewards: position.accrued_rewards, }; } diff --git a/src/datastore/common.ts b/src/datastore/common.ts index 17ee3c406..353c29751 100644 --- a/src/datastore/common.ts +++ b/src/datastore/common.ts @@ -1693,13 +1693,38 @@ export interface DbPrincipalBondPositionInsertValues extends DbTxLocation { btc_paid_out: string; } +/** + * Per-participant reward distribution source row: the sBTC reward sats a single + * participant accrued from one `bond-distribution` event (its share of the + * bond's rewards by staked weight). Backs the running + * `principal_bond_positions.accrued_rewards` total under reorgs. + */ +export interface DbPrincipalBondRewardDistributionInsertValues extends DbTxLocation { + principal: string; + bond_index: number; + reward_amount: string; +} + +/** Per-bond reward distribution, from the pox-5 `bond-distribution` event. */ export interface DbBondRewardDistributionInsertValues extends DbTxLocation { bond_index: number; - remaining_rewards: string; - accrued_rewards: string; - new_reserve: string; - stx_staker_rewards: string; + target_yield: string; + bond_rewards: string; + bond_staked_sats: string; + accrued_rewards_per_sat: string; + cumulative_rewards_per_sat: string; +} + +/** Cycle-level reward calculation aggregate, from the pox-5 `calculate-rewards` event. */ +export interface DbBondRewardCalculationInsertValues extends DbTxLocation { + calculation_height: number; + gross_accrued_rewards: string; + total_bond_rewards: string; + reserve_deposit: string; + reserve_balance: string; stx_cycle: number; + total_stx_staker_rewards: string; cycle_staked_ustx: string; - next_rewards_per_ustx: string; + accrued_rewards_per_ustx: string; + cumulative_rewards_per_ustx: string; } diff --git a/src/datastore/pg-write-store.ts b/src/datastore/pg-write-store.ts index a5f7ce0f7..2b46d64e8 100644 --- a/src/datastore/pg-write-store.ts +++ b/src/datastore/pg-write-store.ts @@ -71,7 +71,9 @@ import { DbBondAllowlistEntryInsertValues, DbPrincipalBondPositionInsertValues, DbPrincipalBondPositionStatus, + DbBondRewardCalculationInsertValues, DbBondRewardDistributionInsertValues, + DbPrincipalBondRewardDistributionInsertValues, } from './common.js'; import { BLOCK_COLUMNS, @@ -109,6 +111,7 @@ import { Pox4EventName, Pox5EventAddToAllowlist, Pox5EventAnnounceL1EarlyExit, + Pox5EventBondDistribution, Pox5EventCalculateRewards, Pox5EventName, Pox5EventRegisterForBond, @@ -571,10 +574,10 @@ export class PgWriteStore extends PgStore { await this.updatePrincipalBondPosition(sql, txLocation, poxEvent); break; case Pox5EventName.CalculateRewards: - await this.updateBondRewardDistribution(sql, txLocation, poxEvent); + await this.updateBondRewardCalculation(sql, txLocation, poxEvent); break; case Pox5EventName.BondDistribution: - // TODO: Implement + await this.updateBondRewardDistribution(sql, txLocation, poxEvent); break; case Pox5EventName.ClaimRewards: break; @@ -825,33 +828,92 @@ export class PgWriteStore extends PgStore { } } + /** Per-bond reward distribution, from the pox-5 `bond-distribution` event. */ private async updateBondRewardDistribution( sql: PgSqlClient, txLocation: DbTxLocation, - event: Pox5EventCalculateRewards + event: Pox5EventBondDistribution ) { - const rewardDistributions: DbBondRewardDistributionInsertValues[] = []; - for (const bondIndex of event.data.bond_periods) { - // TODO: Divide rewards by bond period - rewardDistributions.push({ + const bondIndex = parseInt(event.data.bond_index); + const rewardDistribution: DbBondRewardDistributionInsertValues = { + ...txLocation, + bond_index: bondIndex, + target_yield: event.data.target_yield, + bond_rewards: event.data.bond_rewards, + bond_staked_sats: event.data.bond_staked_sats, + accrued_rewards_per_sat: event.data.accrued_rewards_per_sat, + cumulative_rewards_per_sat: event.data.cumulative_rewards_per_sat, + }; + await sql` + INSERT INTO bond_reward_distributions ${sql(rewardDistribution)} + `; + + // Split this distribution across the bond's participants by their staked + // weight: each participant accrues `floor(staked_sats * per_sat / PRECISION)` + // (PRECISION = 1e18). The bond's per-sat rate is uniform, so this is the + // participant's exact share (modulo integer-rounding dust). + const perSat = event.data.accrued_rewards_per_sat; + const participantRewards = await sql<{ principal: string; reward_amount: string }[]>` + SELECT principal, + floor(btc_locked::numeric * ${perSat}::numeric / 1000000000000000000)::text AS reward_amount + FROM principal_bond_positions + WHERE bond_index = ${bondIndex} + AND canonical = true + AND microblock_canonical = true + AND floor(btc_locked::numeric * ${perSat}::numeric / 1000000000000000000) > 0 + `; + if (participantRewards.length === 0) { + return; + } + const rewardRows: DbPrincipalBondRewardDistributionInsertValues[] = participantRewards.map( + p => ({ ...txLocation, - bond_index: parseInt(bondIndex), - remaining_rewards: event.data.remaining_rewards, - accrued_rewards: event.data.accrued_rewards, - new_reserve: event.data.new_reserve ?? '0', - stx_staker_rewards: event.data.stx_staker_rewards, - stx_cycle: parseInt(event.data.stx_cycle), - cycle_staked_ustx: event.data.cycle_staked_ustx, - next_rewards_per_ustx: event.data.next_rewards_per_ustx, - }); + principal: p.principal, + bond_index: bondIndex, + reward_amount: p.reward_amount, + }) + ); + for (const batch of batchIterate(rewardRows, INSERT_BATCH_SIZE)) { + await sql` + INSERT INTO principal_bond_reward_distributions ${sql(batch)} + `; } - for (const batch of batchIterate(rewardDistributions, INSERT_BATCH_SIZE)) { + for (const p of participantRewards) { await sql` - INSERT INTO bond_reward_distributions ${sql(batch)} + UPDATE principal_bond_positions + SET accrued_rewards = accrued_rewards + ${p.reward_amount}::numeric + WHERE principal = ${p.principal} + AND bond_index = ${bondIndex} + AND canonical = true + AND microblock_canonical = true `; } } + /** Cycle-level reward aggregate, from the pox-5 `calculate-rewards` event. */ + private async updateBondRewardCalculation( + sql: PgSqlClient, + txLocation: DbTxLocation, + event: Pox5EventCalculateRewards + ) { + const rewardCalculation: DbBondRewardCalculationInsertValues = { + ...txLocation, + calculation_height: parseInt(event.data.calculation_height), + gross_accrued_rewards: event.data.gross_accrued_rewards, + total_bond_rewards: event.data.total_bond_rewards, + reserve_deposit: event.data.reserve_deposit, + reserve_balance: event.data.reserve_balance, + stx_cycle: parseInt(event.data.stx_cycle), + total_stx_staker_rewards: event.data.total_stx_staker_rewards, + cycle_staked_ustx: event.data.cycle_staked_ustx, + accrued_rewards_per_ustx: event.data.accrued_rewards_per_ustx, + cumulative_rewards_per_ustx: event.data.cumulative_rewards_per_ustx, + }; + await sql` + INSERT INTO bond_reward_calculations ${sql(rewardCalculation)} + `; + } + private async updateBondAllowlistEntry( sql: PgSqlClient, txLocation: DbTxLocation, @@ -4059,7 +4121,12 @@ export class PgWriteStore extends PgStore { }); // pox-5 tables that only need their canonical flag flipped (no derived // bond counters depend on them). - for (const pox5Table of ['pox5_events', 'bonds', 'bond_reward_distributions']) { + for (const pox5Table of [ + 'pox5_events', + 'bonds', + 'bond_reward_distributions', + 'bond_reward_calculations', + ]) { q.enqueue(async () => { await sql` UPDATE ${sql(pox5Table)} @@ -4140,6 +4207,28 @@ export class PgWriteStore extends PgStore { WHERE b.bond_index = c.bond_index `; }); + q.enqueue(async () => { + // Flip the per-participant reward source rows and apply the signed delta to + // each participant's running accrued_rewards total (ft_events → ft_balances). + await sql` + WITH updated AS ( + UPDATE principal_bond_reward_distributions + SET canonical = ${canonical} + WHERE index_block_hash = ${indexBlockHash} AND canonical != ${canonical} + RETURNING principal, bond_index, reward_amount, canonical + ), + changes AS ( + SELECT principal, bond_index, + SUM(CASE WHEN canonical THEN reward_amount::numeric ELSE -reward_amount::numeric END) AS reward_change + FROM updated + GROUP BY principal, bond_index + ) + UPDATE principal_bond_positions p + SET accrued_rewards = p.accrued_rewards + c.reward_change + FROM changes c + WHERE p.principal = c.principal AND p.bond_index = c.bond_index + `; + }); q.enqueue(async () => { const contractLogResult = await sql<{ contract_identifier: string; delta: number }[]>` WITH updated AS ( diff --git a/src/datastore/v3/constants.ts b/src/datastore/v3/constants.ts index cf90f001a..6ad6766ac 100644 --- a/src/datastore/v3/constants.ts +++ b/src/datastore/v3/constants.ts @@ -155,5 +155,6 @@ export const PRINCIPAL_BOND_POSITION_COLUMNS = [ 'btc_locked', 'stx_locked', 'btc_paid_out', + 'accrued_rewards', 'tx_id', ]; diff --git a/src/datastore/v3/types.ts b/src/datastore/v3/types.ts index b581137ef..9d91caacf 100644 --- a/src/datastore/v3/types.ts +++ b/src/datastore/v3/types.ts @@ -178,6 +178,7 @@ export interface DbPrincipalBondPosition { btc_locked: string; stx_locked: string; btc_paid_out: string; + accrued_rewards: string; tx_id: string; } diff --git a/tests/api/pox5/bonds.test.ts b/tests/api/pox5/bonds.test.ts index 75a8c0e1f..c56ad2c95 100644 --- a/tests/api/pox5/bonds.test.ts +++ b/tests/api/pox5/bonds.test.ts @@ -97,6 +97,7 @@ interface PrincipalBondPositionItem { balances: { locked: { btc: string; stx: string }; paid_out: { btc: string } }; enrollment: { tx_id: string; btc_lockup: { amount: string } }; amount: string; + accrued_rewards: string; } const normalizeTxId = (txid: string) => txid.replace(/^0x/, '').toLowerCase(); @@ -242,9 +243,9 @@ describe('pox-5 bonds (simulated ingestion)', () => { assert.equal(reg.is_l1_lock, false); }); - test("alice's position appears in GET .../principals/:principal/positions", async () => { + test("alice's position appears in GET .../principals/:principal/balances/staking", async () => { const positions = await getJson( - `/extended/v3/staking/principals/${ALICE}/positions` + `/extended/v3/principals/${ALICE}/balances/staking` ); const pos = positions.find(p => p.bond_index === BOND_INDEX); assert.ok(pos, `alice has a position for bond #${BOND_INDEX}`); @@ -492,7 +493,7 @@ describe('pox-5 bonds reorg handling', () => { assert.ok((await canonicalBondEventCount()) > 0, 'pox5_events canonical on fork A'); const regs = await getJson>(`/extended/v3/staking/bonds/${BOND_INDEX}/registrations?limit=50`); assert.equal(regs.results.length, 1, 'registration visible on fork A'); - const positions = await getJson(`/extended/v3/staking/principals/${ALICE}/positions`); + const positions = await getJson(`/extended/v3/principals/${ALICE}/balances/staking`); assert.equal(positions.length, 1, 'position visible on fork A'); // Fork B overtakes fork A (height 2 then 3) — no bond on this fork. @@ -523,7 +524,7 @@ describe('pox-5 bonds reorg handling', () => { `/extended/v3/staking/bonds/${BOND_INDEX}/registrations?limit=50` ); assert.equal(regsAfter.results.length, 0, 'registration gone after reorg'); - const positionsAfter = await getJson(`/extended/v3/staking/principals/${ALICE}/positions`); + const positionsAfter = await getJson(`/extended/v3/principals/${ALICE}/balances/staking`); assert.equal(positionsAfter.length, 0, 'position gone after reorg'); // Fork A wins again (extends to height 4) — the bond is restored. @@ -553,7 +554,7 @@ describe('pox-5 bonds reorg handling', () => { ); assert.equal(regsRestored.results.length, 1, 'registration restored'); const positionsRestored = await getJson( - `/extended/v3/staking/principals/${ALICE}/positions` + `/extended/v3/principals/${ALICE}/balances/staking` ); assert.equal(positionsRestored.length, 1, 'position restored'); }); @@ -662,7 +663,7 @@ describe('pox-5 bonds reorg handling', () => { assert.equal(regs.results.length, 0, 'registration orphaned'); assert.equal(regs.total, 0, 'registration total reverted'); const positions = await getJson( - `/extended/v3/staking/principals/${ALICE}/positions` + `/extended/v3/principals/${ALICE}/balances/staking` ); assert.equal(positions.length, 0, 'position orphaned'); }); @@ -692,7 +693,7 @@ describe('pox-5 bonds unstake / early-exit', () => { return pos; } const getPositions = () => - getJson(`/extended/v3/staking/principals/${ALICE}/positions`); + getJson(`/extended/v3/principals/${ALICE}/balances/staking`); const getBond = () => getJson(`/extended/v3/staking/bonds/${BOND_INDEX}`); beforeEach(async () => { @@ -815,3 +816,161 @@ describe('pox-5 bonds unstake / early-exit', () => { assert.equal(BigInt((await getBond()).balances.locked.btc), SBTC_SATS, 'bond btc_locked unchanged'); }); }); + +/** + * Reward accrual: each pox-5 `bond-distribution` event is split across the + * bond's participants by staked weight and accrued onto their position; the + * accrual is reorg-safe (reverts if the distribution block is orphaned). + */ +describe('pox-5 bonds reward accrual', () => { + let db: PgWriteStore; + let api: ApiServer; + + const BOB_REGISTER_TX_ID = '0x' + '33'.repeat(32); + const DIST_TX_ID = '0x' + 'd1'.repeat(32); + const ALICE_SATS = 1_000n; + const BOB_SATS = 4_000n; + // 2 reward sats per staked sat (PRECISION = 1e18). + const ACCRUED_PER_SAT = (2n * 1_000_000_000_000_000_000n).toString(); + const ALICE_EXPECTED = ALICE_SATS * 2n; // 2000 + const BOB_EXPECTED = BOB_SATS * 2n; // 8000 + + async function getJson(path: string): Promise { + const res = await supertest(api.server).get(path); + assert.equal(res.status, 200, `GET ${path} -> ${res.status}: ${res.text}`); + return JSON.parse(res.text) as T; + } + async function accruedFor(principal: string): Promise { + const positions = await getJson( + `/extended/v3/principals/${principal}/balances/staking` + ); + const pos = positions.find(p => p.bond_index === BOND_INDEX); + assert.ok(pos, `position for ${principal}`); + return BigInt(pos.accrued_rewards); + } + function registerEvent(staker: string, sats: bigint) { + return { + bond_index: String(BOND_INDEX), + signer: SIGNER, + staker, + amount_ustx: AMOUNT_USTX.toString(), + sats_total: sats.toString(), + first_reward_cycle: String(FIRST_REWARD_CYCLE), + unlock_burn_height: String(UNLOCK_BURN_HEIGHT), + unlock_cycle: String(UNLOCK_CYCLE), + is_l1_lock: false, + }; + } + function distributionBlock(args: { + block_height: number; + block_hash: string; + index_block_hash: string; + parent_block_hash: string; + parent_index_block_hash: string; + }) { + return new TestBlockBuilder(args) + .addTx({ tx_id: DIST_TX_ID }) + .addTxPox5Event({ + name: Pox5EventName.BondDistribution, + data: { + bond_index: String(BOND_INDEX), + target_yield: '0', + bond_rewards: ((ALICE_SATS + BOB_SATS) * 2n).toString(), + bond_staked_sats: (ALICE_SATS + BOB_SATS).toString(), + accrued_rewards_per_sat: ACCRUED_PER_SAT, + cumulative_rewards_per_sat: ACCRUED_PER_SAT, + }, + }) + .build(); + } + + beforeEach(async () => { + await migrate('up'); + db = await PgWriteStore.connect({ usageName: 'tests', withNotifier: false, skipMigrations: true }); + api = await startApiServer({ datastore: db, chainId: STACKS_TESTNET.chainId }); + + // Seed (block 1): bond with alice + bob registered at different weights. + await db.update( + new TestBlockBuilder({ block_height: 1, block_hash: '0x01', index_block_hash: '0x01' }) + .addTx({ tx_id: SETUP_TX_ID }) + .addTxPox5Event({ name: Pox5EventName.SetupBond, data: SETUP_BOND_DATA }) + .addTxPox5Event({ + name: Pox5EventName.AddToAllowlist, + data: { bond_index: String(BOND_INDEX), staker: ALICE, max_sats: ALICE_MAX_SATS.toString() }, + }) + .addTxPox5Event({ + name: Pox5EventName.AddToAllowlist, + data: { bond_index: String(BOND_INDEX), staker: BOB, max_sats: BOB_MAX_SATS.toString() }, + }) + .addTx({ tx_id: REGISTER_TX_ID }) + .addTxPox5Event({ name: Pox5EventName.RegisterForBond, data: registerEvent(ALICE, ALICE_SATS) }) + .addTx({ tx_id: BOB_REGISTER_TX_ID }) + .addTxPox5Event({ name: Pox5EventName.RegisterForBond, data: registerEvent(BOB, BOB_SATS) }) + .build() + ); + }); + + afterEach(async () => { + await api.terminate(); + await db?.close(); + await migrate('down'); + }); + + test('a bond-distribution accrues rewards to participants by staked weight', async () => { + // No rewards before the distribution. + assert.equal(await accruedFor(ALICE), 0n); + assert.equal(await accruedFor(BOB), 0n); + + await db.update( + distributionBlock({ + block_height: 2, + block_hash: '0x02', + index_block_hash: '0x02', + parent_block_hash: '0x01', + parent_index_block_hash: '0x01', + }) + ); + + // alice has 1/5 of the weight, bob 4/5 — split 2 sats per staked sat. + assert.equal(await accruedFor(ALICE), ALICE_EXPECTED); + assert.equal(await accruedFor(BOB), BOB_EXPECTED); + }); + + test('orphaning the distribution block reverts the accrued rewards', async () => { + await db.update( + distributionBlock({ + block_height: 2, + block_hash: '0x02', + index_block_hash: '0x02', + parent_block_hash: '0x01', + parent_index_block_hash: '0x01', + }) + ); + assert.equal(await accruedFor(ALICE), ALICE_EXPECTED); + assert.equal(await accruedFor(BOB), BOB_EXPECTED); + + // Fork B branches from the seed (block 1) and overtakes, orphaning the + // distribution block — the seed/positions survive, the accrual reverts. + await db.update( + new TestBlockBuilder({ + block_height: 2, + block_hash: '0xb2', + index_block_hash: '0xb2', + parent_block_hash: '0x01', + parent_index_block_hash: '0x01', + }).build() + ); + await db.update( + new TestBlockBuilder({ + block_height: 3, + block_hash: '0xb3', + index_block_hash: '0xb3', + parent_block_hash: '0xb2', + parent_index_block_hash: '0xb2', + }).build() + ); + + assert.equal(await accruedFor(ALICE), 0n, 'alice accrual reverted'); + assert.equal(await accruedFor(BOB), 0n, 'bob accrual reverted'); + }); +});