diff --git a/migrations/1779742831642_principal-bond-positions.ts b/migrations/1779742831642_principal-bond-positions.ts index eb89b26d6..d3b0bed33 100644 --- a/migrations/1779742831642_principal-bond-positions.ts +++ b/migrations/1779742831642_principal-bond-positions.ts @@ -95,6 +95,15 @@ export function up(pgm: MigrationBuilder): void { notNull: true, default: 0, }, + // Running sBTC reward sats already claimed against this position, from + // pox-5 `claim-staker-rewards-for-signer` events. Claimable rewards are + // `accrued_rewards - claimed_rewards`. Maintained incrementally on write + // (and via signed deltas on reorg). + claimed_rewards: { + type: 'numeric', + notNull: true, + default: 0, + }, }); pgm.createIndex('principal_bond_positions', ['principal', 'bond_index'], { diff --git a/migrations/1779745340755_principal-bond-reward-claims.ts b/migrations/1779745340755_principal-bond-reward-claims.ts new file mode 100644 index 000000000..daa2f25de --- /dev/null +++ b/migrations/1779745340755_principal-bond-reward-claims.ts @@ -0,0 +1,102 @@ +import type { ColumnDefinitions, MigrationBuilder } from 'node-pg-migrate'; + +export const shorthands: ColumnDefinitions | undefined = undefined; + +/** + * Per-staker reward claims, one row per pox-5 `claim-staker-rewards-for-signer` + * event. These are the source rows behind each position's running + * `principal_bond_positions.claimed_rewards` total: claims with a `bond_index` + * are bond (sBTC) reward claims and feed that total; claims with a NULL + * `bond_index` are STX-staking reward claims (no bond position attached). + * Keeping the per-claim rows lets us re-derive or delta-correct the running + * totals under reorgs. + */ +export function up(pgm: MigrationBuilder): void { + pgm.createTable('principal_bond_reward_claims', { + id: { + type: 'bigserial', + primaryKey: true, + }, + // The staker whose rewards were claimed. + principal: { + type: 'string', + notNull: true, + }, + // The signer manager that performed the claim. + signer_manager: { + type: 'string', + notNull: true, + }, + reward_cycle: { + type: 'integer', + notNull: true, + }, + // Bond the claimed rewards accrued against; NULL for STX-staking rewards. + bond_index: { + type: 'integer', + }, + rewards_claimed: { + 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_claims', 'tx_id'); + pgm.createIndex('principal_bond_reward_claims', ['index_block_hash', 'canonical']); + pgm.createIndex('principal_bond_reward_claims', ['principal', 'bond_index']); +} + +export function down(pgm: MigrationBuilder): void { + pgm.dropTable('principal_bond_reward_claims'); +} diff --git a/migrations/1779800000003_principal-staking-totals.ts b/migrations/1779800000003_principal-staking-totals.ts new file mode 100644 index 000000000..2e31d41a9 --- /dev/null +++ b/migrations/1779800000003_principal-staking-totals.ts @@ -0,0 +1,71 @@ +import type { ColumnDefinitions, MigrationBuilder } from 'node-pg-migrate'; + +export const shorthands: ColumnDefinitions | undefined = undefined; + +/** + * Materialized per-principal staking summary — one row per principal, holding + * every total the staking-summary endpoint needs so it's a single-row lookup + * rather than an on-read aggregate. Maintained incrementally on write (and + * delta-corrected on reorg), like `ft_balances`: + * + * - `stx_*` columns: the principal's pox-5 STX-staking sBTC rewards, fed by + * `principal_stx_reward_distributions` (accruals) and the NULL-`bond_index` + * rows of `principal_bond_reward_claims` (claims). + * - `bond_*` columns: a rollup of the principal's `principal_bond_positions` + * rows (count + summed locked amounts and sBTC rewards) — the per-principal + * analogue of the per-bond totals on the `bonds` table. + * + * The pox-5 STX *locked* amount is not materialized here: it depends on the + * current burn height (expiry) with no event firing at expiry, so it stays + * resolved-on-read from `stx_locked_balances`. + */ +export function up(pgm: MigrationBuilder): void { + pgm.createTable('principal_staking_totals', { + principal: { + type: 'text', + notNull: true, + primaryKey: true, + }, + // STX-staking sBTC reward totals. + stx_accrued_rewards: { + type: 'numeric', + notNull: true, + default: 0, + }, + stx_claimed_rewards: { + type: 'numeric', + notNull: true, + default: 0, + }, + // Rollup of the principal's bond positions. + bond_count: { + type: 'integer', + notNull: true, + default: 0, + }, + bond_btc_locked: { + type: 'numeric', + notNull: true, + default: 0, + }, + bond_stx_locked: { + type: 'numeric', + notNull: true, + default: 0, + }, + bond_accrued_rewards: { + type: 'numeric', + notNull: true, + default: 0, + }, + bond_claimed_rewards: { + type: 'numeric', + notNull: true, + default: 0, + }, + }); +} + +export function down(pgm: MigrationBuilder): void { + pgm.dropTable('principal_staking_totals'); +} diff --git a/migrations/1779800000004_principal-stx-reward-distributions.ts b/migrations/1779800000004_principal-stx-reward-distributions.ts new file mode 100644 index 000000000..acc8dc49a --- /dev/null +++ b/migrations/1779800000004_principal-stx-reward-distributions.ts @@ -0,0 +1,93 @@ +import type { ColumnDefinitions, MigrationBuilder } from 'node-pg-migrate'; + +export const shorthands: ColumnDefinitions | undefined = undefined; + +/** + * Per-staker STX-staking reward distribution source rows. Each pox-5 + * `calculate-rewards` event allocates `total_stx_staker_rewards` (sBTC sats) to + * STX stackers at a uniform `accrued_rewards_per_ustx` rate; we split that rate + * across the current pox-5 STX lockers by their locked uSTX and write one row + * here per staker per calculation. These rows back the running + * `principal_staking_totals.stx_accrued_rewards` total under reorgs — analogous + * to `principal_bond_reward_distributions` for bond rewards. + */ +export function up(pgm: MigrationBuilder): void { + pgm.createTable('principal_stx_reward_distributions', { + id: { + type: 'bigserial', + primaryKey: true, + }, + principal: { + type: 'text', + notNull: true, + }, + reward_cycle: { + type: 'integer', + notNull: true, + }, + // sBTC reward sats this staker accrued from this calculation. + 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_stx_reward_distributions', 'tx_id'); + pgm.createIndex('principal_stx_reward_distributions', ['index_block_hash', 'canonical']); + pgm.createIndex('principal_stx_reward_distributions', 'principal'); +} + +export function down(pgm: MigrationBuilder): void { + pgm.dropTable('principal_stx_reward_distributions'); +} diff --git a/migrations/1779800000005_signer-reward-claims.ts b/migrations/1779800000005_signer-reward-claims.ts new file mode 100644 index 000000000..9f090b767 --- /dev/null +++ b/migrations/1779800000005_signer-reward-claims.ts @@ -0,0 +1,113 @@ +import type { ColumnDefinitions, MigrationBuilder } from 'node-pg-migrate'; + +export const shorthands: ColumnDefinitions | undefined = undefined; + +/** + * Per-signer reward claim aggregate, one row per pox-5 `claim-rewards` event. + * This is the signer-manager-level claim: the sBTC rewards a signer manager + * claimed for a cycle, broken into the STX-staking portion (`stx_earned`) and + * the per-bond portion (`bond_rewards`), with `total_rewards` the sum. It is + * bookkeeping/audit data with no running totals derived from it — the + * per-staker effects live in `principal_bond_reward_claims` / + * `principal_staking_totals` — so reorgs only flip its canonical flag. + */ +export function up(pgm: MigrationBuilder): void { + pgm.createTable('signer_reward_claims', { + id: { + type: 'bigserial', + primaryKey: true, + }, + signer_manager: { + type: 'text', + notNull: true, + }, + reward_cycle: { + type: 'integer', + notNull: true, + }, + // sBTC reward sats earned by this signer's STX stakers for the cycle. + stx_earned: { + type: 'numeric', + notNull: true, + }, + // The cumulative per-uSTX reward rate at claim time (from `stx_rewards`). + stx_rewards_per_token: { + type: 'numeric', + notNull: true, + }, + // JSON array of `{ bond_index, earned, rewards_per_token }` per claimed bond. + bond_rewards: { + type: 'jsonb', + notNull: true, + }, + // Total sBTC reward sats claimed across all of this signer's bonds. + bond_totals: { + type: 'numeric', + notNull: true, + }, + // Total sBTC reward sats claimed (STX-staking + bonds). + total_rewards: { + 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('signer_reward_claims', 'tx_id'); + pgm.createIndex('signer_reward_claims', ['index_block_hash', 'canonical']); + pgm.createIndex('signer_reward_claims', ['signer_manager', 'reward_cycle']); +} + +export function down(pgm: MigrationBuilder): void { + pgm.dropTable('signer_reward_claims'); +} diff --git a/src/api/routes/v3/principals.ts b/src/api/routes/v3/principals.ts index b82875042..d56de6dc7 100644 --- a/src/api/routes/v3/principals.ts +++ b/src/api/routes/v3/principals.ts @@ -8,6 +8,7 @@ import { Server } from 'node:http'; import { getPagingQueryLimit, ResourceType } from '../../pagination.js'; import { PrincipalSchema } from '../../schemas/v3/entities/common.js'; import { + BondCursorSchema, CursorPaginationQuerystring, CursorPaginatedResponse, FtBalanceCursorSchema, @@ -17,8 +18,14 @@ import { import { decodeClarityValueToRepr } from '@stacks/codec'; 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 { + PrincipalBondPositionSchema, + PrincipalStakingSummarySchema, +} from '../../schemas/v3/entities/principal-bond-positions.js'; +import { + serializeDbPrincipalBondPosition, + serializeDbPrincipalStakingSummary, +} from '../../serializers/v3/bonds.js'; import { handleChainTipCache } from '../../controllers/cache-controller.js'; import { PrincipalFtPositionSchema, @@ -72,26 +79,66 @@ export const PrincipalsRoutes: FastifyPluginAsync< ); fastify.get( - '/principals/:principal/balances/staking', + '/principals/:principal/staking', { preHandler: handleChainTipCache, schema: { - operationId: 'get_principal_balances_staking', - summary: 'Get principal staking balances', + operationId: 'get_principal_staking_summary', + summary: 'Get principal staking summary', description: - "Get a principal's staking balances: its bond positions (staked amounts and accrued rewards) across all bonds it is enrolled in", + "A one-call overview of a principal's staking: its pox-5 STX-staking position (locked STX and its sBTC rewards) plus aggregate totals across all of its bond positions. The per-bond breakdown is paginated at `/principals/:principal/staking/bonds`.", tags: ['Staking'], params: Type.Object({ principal: PrincipalSchema }), response: { - 200: Type.Array(PrincipalBondPositionSchema), + 200: PrincipalStakingSummarySchema, }, }, }, async (req, reply) => { - const positions = await fastify.db.v3.getPrincipalStakingPositions({ + const summary = await fastify.db.v3.getPrincipalStakingSummary({ principal: req.params.principal, }); - await reply.send(positions.map(serializeDbPrincipalBondPosition)); + await reply.send(serializeDbPrincipalStakingSummary(summary)); + } + ); + + fastify.get( + '/principals/:principal/staking/bonds', + { + preHandler: handleChainTipCache, + schema: { + operationId: 'get_principal_bond_positions', + summary: 'Get principal bond positions', + description: + "Get a principal's bond positions — its enrollment, lock, status, and sBTC rewards in each bond it participates in.", + tags: ['Staking'], + params: Type.Object({ principal: PrincipalSchema }), + querystring: CursorPaginationQuerystring(BondCursorSchema, ResourceType.Tx), + response: { + 200: CursorPaginatedResponse( + PrincipalBondPositionSchema, + BondCursorSchema, + ResourceType.Tx + ), + }, + }, + }, + async (req, reply) => { + const results = await fastify.db.v3.getPrincipalBondPositions({ + principal: req.params.principal, + limit: req.query.limit ?? getPagingQueryLimit(ResourceType.Tx), + cursor: req.query.cursor, + }); + await reply.send({ + limit: results.limit, + total: results.total, + cursor: { + next: results.next_cursor, + previous: results.prev_cursor, + current: results.current_cursor, + }, + results: results.results.map(serializeDbPrincipalBondPosition), + }); } ); diff --git a/src/api/schemas/v3/entities/principal-bond-positions.ts b/src/api/schemas/v3/entities/principal-bond-positions.ts index b0106bff8..a734e2339 100644 --- a/src/api/schemas/v3/entities/principal-bond-positions.ts +++ b/src/api/schemas/v3/entities/principal-bond-positions.ts @@ -1,5 +1,4 @@ import { Static, Type } from '@sinclair/typebox'; -import { BondBalancesSchema } from './bonds.js'; import { BondIndexSchema, TransactionIdSchema } from './common.js'; export const PrincipalBondPositionStatusSchema = Type.Union([ @@ -10,13 +9,27 @@ export const PrincipalBondPositionStatusSchema = Type.Union([ ]); export type PrincipalBondPositionStatus = Static; +/** Lifetime sBTC reward sats earned by a staking position (accrued / claimed / outstanding). */ +export const BtcRewardsSchema = Type.Object({ + accrued: Type.String({ + description: 'The lifetime sBTC reward sats accrued to this position', + }), + claimed: Type.String({ + description: 'The lifetime sBTC reward sats already claimed against this position', + }), + claimable: Type.String({ + description: 'The sBTC reward sats currently claimable (accrued minus claimed)', + }), +}); +export type BtcRewards = Static; + +/** A principal's position in a single bond: its enrollment, lock, status, and rewards. */ export const PrincipalBondPositionSchema = Type.Object({ bond_index: BondIndexSchema, status: PrincipalBondPositionStatusSchema, active: Type.Boolean({ description: 'Whether the position is active', }), - balances: BondBalancesSchema, enrollment: Type.Object({ tx_id: TransactionIdSchema, btc_lockup: Type.Object({ @@ -25,11 +38,47 @@ export const PrincipalBondPositionSchema = Type.Object({ }), }), }), - amount: Type.String({ - description: 'The amount of STX that is locked up for this principal', + locked: Type.Object({ + btc: Type.String({ description: 'The amount of BTC locked in this bond position' }), + stx: Type.String({ description: 'The amount of STX locked in this bond position' }), }), - accrued_rewards: Type.String({ - description: "The sBTC reward sats accrued to this participant's position", + rewards: Type.Object({ + btc: BtcRewardsSchema, }), }); export type PrincipalBondPosition = Static; + +/** + * A principal's pox-5 STX-staking position: the uSTX it currently has locked in + * STX staking, and the sBTC rewards that staking has earned. Rewards persist + * after unstaking until claimed, so `locked` can be `"0"` while rewards remain. + */ +export const PrincipalStxStakingPositionSchema = Type.Object({ + locked: Type.String({ + description: 'The amount of uSTX currently locked in pox-5 STX staking', + }), + rewards: Type.Object({ + btc: BtcRewardsSchema, + }), +}); +export type PrincipalStxStakingPosition = Static; + +/** + * A one-call overview of a principal's staking: its (singleton) STX-staking + * position plus aggregate totals across all of its bond positions. The per-bond + * breakdown is paginated separately at `/principals/:principal/staking/bonds`. + */ +export const PrincipalStakingSummarySchema = Type.Object({ + stx: PrincipalStxStakingPositionSchema, + bonds: Type.Object({ + count: Type.Integer({ description: 'Number of bonds this principal has a position in' }), + locked: Type.Object({ + btc: Type.String({ description: 'Total BTC locked across all bond positions' }), + stx: Type.String({ description: 'Total STX locked across all bond positions' }), + }), + rewards: Type.Object({ + btc: BtcRewardsSchema, + }), + }), +}); +export type PrincipalStakingSummary = Static; diff --git a/src/api/schemas/v3/responses/principal-staking-balances-response.ts b/src/api/schemas/v3/responses/principal-staking-balances-response.ts deleted file mode 100644 index ce1deb10d..000000000 --- a/src/api/schemas/v3/responses/principal-staking-balances-response.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Static, Type } from '@sinclair/typebox'; - -export const PrincipalStakingBalancesResponseSchema = Type.Object({ - bonds: Type.String({ - description: 'The total amount of STX that is locked up for this principal', - }), - stx: Type.String({ - description: 'The total amount of STX that is locked up for this principal', - }), -}); -export type PrincipalStakingBalancesResponse = Static< - typeof PrincipalStakingBalancesResponseSchema ->; diff --git a/src/api/serializers/v3/bonds.ts b/src/api/serializers/v3/bonds.ts index 1d17fc4f9..3f4a8d47d 100644 --- a/src/api/serializers/v3/bonds.ts +++ b/src/api/serializers/v3/bonds.ts @@ -5,6 +5,7 @@ import { DbBondRegistrationSummary, DbBondSummary, DbPrincipalBondPosition, + DbPrincipalStakingSummary, } from '../../../datastore/v3/types.js'; import { DbBondLockupType, DbPrincipalBondPositionStatus } from '../../../datastore/common.js'; import { Bond, BondSummary } from '../../schemas/v3/entities/bonds.js'; @@ -13,11 +14,22 @@ import { BondAllowlist } from '../../schemas/v3/entities/bond-allowlist-entries. import { BondRegistration } from '../../schemas/v3/entities/bond-registrations.js'; import { BondRegistrationSummary } from '../../schemas/v3/entities/bond-registration-summaries.js'; import { + BtcRewards, PrincipalBondPosition, PrincipalBondPositionStatus, + PrincipalStakingSummary, } from '../../schemas/v3/entities/principal-bond-positions.js'; -function getBondStatus(summary: DbBondSummary, currentBurnBlockHeight: number): BondStatus { +/** Build the `{ accrued, claimed, claimable }` sBTC reward triple from running totals. */ +function serializeBtcRewards(accrued: string, claimed: string): BtcRewards { + return { + accrued, + claimed, + claimable: (BigInt(accrued) - BigInt(claimed)).toString(), + }; +} + +function serializeBondStatus(summary: DbBondSummary, currentBurnBlockHeight: number): BondStatus { if (currentBurnBlockHeight < summary.bond_start_height) { return 'upcoming'; } @@ -39,7 +51,7 @@ export function serializeDbBondSummary( return { index: summary.bond_index, pox_version: 'pox5', - status: getBondStatus(summary, currentBurnBlockHeight), + status: serializeBondStatus(summary, currentBurnBlockHeight), parameters: { target_rate_bps: summary.target_rate, stx_value_ratio: summary.stx_value_ratio, @@ -104,7 +116,7 @@ export function serializeDbBondAllowlistEntry(entry: DbBondAllowlistEntry): Bond }; } -function getPrincipalBondPositionStatus( +function serializePrincipalBondPositionStatus( status: DbPrincipalBondPositionStatus ): PrincipalBondPositionStatus { switch (status) { @@ -119,7 +131,7 @@ function getPrincipalBondPositionStatus( } } -function getBondLockupType(type: DbBondLockupType): 'l1' | 'l2' { +function serializeBondLockupType(type: DbBondLockupType): 'l1' | 'l2' { switch (type) { case DbBondLockupType.L1: return 'l1'; @@ -133,25 +145,44 @@ export function serializeDbPrincipalBondPosition( ): PrincipalBondPosition { return { bond_index: position.bond_index, - status: getPrincipalBondPositionStatus(position.status), + status: serializePrincipalBondPositionStatus(position.status), active: position.active, - balances: { - locked: { - btc: position.btc_locked, - stx: position.stx_locked, - }, - paid_out: { - btc: position.btc_paid_out, - }, - }, enrollment: { tx_id: position.tx_id, btc_lockup: { amount: position.btc_locked, }, }, - amount: position.stx_locked, - accrued_rewards: position.accrued_rewards, + locked: { + btc: position.btc_locked, + stx: position.stx_locked, + }, + rewards: { + btc: serializeBtcRewards(position.accrued_rewards, position.claimed_rewards), + }, + }; +} + +export function serializeDbPrincipalStakingSummary( + summary: DbPrincipalStakingSummary +): PrincipalStakingSummary { + return { + stx: { + locked: summary.stx.locked, + rewards: { + btc: serializeBtcRewards(summary.stx.accrued_rewards, summary.stx.claimed_rewards), + }, + }, + bonds: { + count: summary.bonds.count, + locked: { + btc: summary.bonds.btc_locked, + stx: summary.bonds.stx_locked, + }, + rewards: { + btc: serializeBtcRewards(summary.bonds.accrued_rewards, summary.bonds.claimed_rewards), + }, + }, }; } @@ -161,7 +192,7 @@ export function serializeDbBondRegistrationSummary( return { signer: entry.signer, staker: entry.staker, - type: getBondLockupType(entry.btc_lockup_type), + type: serializeBondLockupType(entry.btc_lockup_type), balances: { btc: entry.sats_total, stx: entry.amount_ustx, diff --git a/src/datastore/common.ts b/src/datastore/common.ts index dc35f0947..33ba5e814 100644 --- a/src/datastore/common.ts +++ b/src/datastore/common.ts @@ -1728,6 +1728,48 @@ export interface DbPrincipalBondRewardDistributionInsertValues extends DbTxLocat reward_amount: string; } +/** + * Per-staker reward claim source row, from the pox-5 + * `claim-staker-rewards-for-signer` event. Claims with a `bond_index` back the + * running `principal_bond_positions.claimed_rewards` total under reorgs; claims + * with a null `bond_index` are STX-staking reward claims. + */ +export interface DbPrincipalBondRewardClaimInsertValues extends DbTxLocation { + principal: string; + signer_manager: string; + reward_cycle: number; + bond_index: number | null; + rewards_claimed: string; +} + +/** + * Per-staker STX-staking reward distribution source row, from a pox-5 + * `calculate-rewards` event: the sBTC reward sats a single STX locker accrued + * from one calculation (its share of `total_stx_staker_rewards` by locked + * weight). Backs the running `principal_staking_totals.stx_accrued_rewards` + * total under reorgs. + */ +export interface DbPrincipalStxRewardDistributionInsertValues extends DbTxLocation { + principal: string; + reward_cycle: number; + reward_amount: string; +} + +/** + * Per-signer reward claim aggregate, from a pox-5 `claim-rewards` event. Pure + * bookkeeping/audit data (no running totals are derived from it). + */ +export interface DbSignerRewardClaimInsertValues extends DbTxLocation { + signer_manager: string; + reward_cycle: number; + stx_earned: string; + stx_rewards_per_token: string; + /** JSON-encoded array of `{ bond_index, earned, rewards_per_token }`. */ + bond_rewards: string; + bond_totals: string; + total_rewards: string; +} + /** Per-bond reward distribution, from the pox-5 `bond-distribution` event. */ export interface DbBondRewardDistributionInsertValues extends DbTxLocation { bond_index: number; diff --git a/src/datastore/pg-write-store.ts b/src/datastore/pg-write-store.ts index e3aa5e562..3c464ed4e 100644 --- a/src/datastore/pg-write-store.ts +++ b/src/datastore/pg-write-store.ts @@ -75,6 +75,9 @@ import { DbBondRewardCalculationInsertValues, DbBondRewardDistributionInsertValues, DbPrincipalBondRewardDistributionInsertValues, + DbPrincipalBondRewardClaimInsertValues, + DbPrincipalStxRewardDistributionInsertValues, + DbSignerRewardClaimInsertValues, } from './common.js'; import { BLOCK_COLUMNS, @@ -115,6 +118,8 @@ import { Pox5EventAnnounceL1EarlyExit, Pox5EventBondDistribution, Pox5EventCalculateRewards, + Pox5EventClaimRewards, + Pox5EventClaimStakerRewardsForSigner, Pox5EventName, Pox5EventRegisterForBond, Pox5EventSetupBond, @@ -583,9 +588,11 @@ export class PgWriteStore extends PgStore { case Pox5EventName.BondDistribution: await this.updateBondRewardDistribution(sql, txLocation, poxEvent); break; - case Pox5EventName.ClaimRewards: case Pox5EventName.ClaimStakerRewardsForSigner: - // TODO: Implement + await this.updatePrincipalBondRewardClaim(sql, txLocation, poxEvent); + break; + case Pox5EventName.ClaimRewards: + await this.updateSignerRewardClaim(sql, txLocation, poxEvent); break; case Pox5EventName.Stake: case Pox5EventName.StakeUpdate: @@ -747,18 +754,31 @@ export class PgWriteStore extends PgStore { upserted_position.btc_locked::numeric - COALESCE(existing_position.btc_locked::numeric, 0) AS btc_delta, upserted_position.stx_locked::numeric - - COALESCE(existing_position.stx_locked::numeric, 0) AS stx_delta + - COALESCE(existing_position.stx_locked::numeric, 0) AS stx_delta, + -- A brand-new canonical position adds 1 to the principal's bond count. + CASE WHEN existing_position.btc_locked IS NULL THEN 1 ELSE 0 END AS count_delta FROM upserted_position LEFT JOIN existing_position ON true + ), + bond_update AS ( + UPDATE bonds + SET + btc_locked = bonds.btc_locked + delta.btc_delta, + stx_locked = bonds.stx_locked + delta.stx_delta + FROM delta + WHERE bonds.bond_index = ${bondIndex} + AND bonds.canonical = true + AND bonds.microblock_canonical = true + RETURNING 1 ) - UPDATE bonds - SET - btc_locked = bonds.btc_locked + delta.btc_delta, - stx_locked = bonds.stx_locked + delta.stx_delta + INSERT INTO principal_staking_totals + (principal, bond_count, bond_btc_locked, bond_stx_locked) + SELECT ${position.principal}, delta.count_delta, delta.btc_delta, delta.stx_delta FROM delta - WHERE bonds.bond_index = ${bondIndex} - AND bonds.canonical = true - AND bonds.microblock_canonical = true + ON CONFLICT (principal) DO UPDATE SET + bond_count = principal_staking_totals.bond_count + EXCLUDED.bond_count, + bond_btc_locked = principal_staking_totals.bond_btc_locked + EXCLUDED.bond_btc_locked, + bond_stx_locked = principal_staking_totals.bond_stx_locked + EXCLUDED.bond_stx_locked `; break; } @@ -829,15 +849,24 @@ export class PgWriteStore extends PgStore { updated_position.bond_index FROM updated_position INNER JOIN existing_position USING (bond_index) + ), + bond_update AS ( + UPDATE bonds + SET + btc_locked = bonds.btc_locked + delta.btc_delta, + stx_locked = bonds.stx_locked + delta.stx_delta + FROM delta + WHERE bonds.bond_index = delta.bond_index + AND bonds.canonical = true + AND bonds.microblock_canonical = true + RETURNING 1 ) - UPDATE bonds - SET - btc_locked = bonds.btc_locked + delta.btc_delta, - stx_locked = bonds.stx_locked + delta.stx_delta + INSERT INTO principal_staking_totals (principal, bond_btc_locked, bond_stx_locked) + SELECT ${event.data.staker}, delta.btc_delta, delta.stx_delta FROM delta - WHERE bonds.bond_index = delta.bond_index - AND bonds.canonical = true - AND bonds.microblock_canonical = true + ON CONFLICT (principal) DO UPDATE SET + bond_btc_locked = principal_staking_totals.bond_btc_locked + EXCLUDED.bond_btc_locked, + bond_stx_locked = principal_staking_totals.bond_stx_locked + EXCLUDED.bond_stx_locked `; return; } @@ -902,7 +931,88 @@ export class PgWriteStore extends PgStore { AND canonical = true AND microblock_canonical = true `; + await sql` + INSERT INTO principal_staking_totals (principal, bond_accrued_rewards) + VALUES (${p.principal}, ${p.reward_amount}::numeric) + ON CONFLICT (principal) DO UPDATE SET + bond_accrued_rewards = principal_staking_totals.bond_accrued_rewards + EXCLUDED.bond_accrued_rewards + `; + } + } + + /** + * Record a per-staker reward claim (pox-5 `claim-staker-rewards-for-signer`) + * and, for bond claims, roll the claimed amount into the position's running + * `claimed_rewards` total. Claims with a null `bond_index` are STX-staking + * reward claims — they only get a source row (no bond position to update). + */ + private async updatePrincipalBondRewardClaim( + sql: PgSqlClient, + txLocation: DbTxLocation, + event: Pox5EventClaimStakerRewardsForSigner + ) { + const bondIndex = event.data.bond_index === null ? null : parseInt(event.data.bond_index); + const claim: DbPrincipalBondRewardClaimInsertValues = { + ...txLocation, + principal: event.data.staker, + signer_manager: event.data.signer_manager, + reward_cycle: parseInt(event.data.reward_cycle), + bond_index: bondIndex, + rewards_claimed: event.data.rewards_claimed, + }; + await sql` + INSERT INTO principal_bond_reward_claims ${sql(claim)} + `; + if (bondIndex === null) { + // STX-staking reward claim (no bond): roll into the staker's running + // STX-staking claimed total. + await sql` + INSERT INTO principal_staking_totals (principal, stx_claimed_rewards) + VALUES (${event.data.staker}, ${event.data.rewards_claimed}::numeric) + ON CONFLICT (principal) DO UPDATE + SET stx_claimed_rewards = principal_staking_totals.stx_claimed_rewards + EXCLUDED.stx_claimed_rewards + `; + return; } + await sql` + UPDATE principal_bond_positions + SET claimed_rewards = claimed_rewards + ${event.data.rewards_claimed}::numeric + WHERE principal = ${event.data.staker} + AND bond_index = ${bondIndex} + AND canonical = true + AND microblock_canonical = true + `; + await sql` + INSERT INTO principal_staking_totals (principal, bond_claimed_rewards) + VALUES (${event.data.staker}, ${event.data.rewards_claimed}::numeric) + ON CONFLICT (principal) DO UPDATE SET + bond_claimed_rewards = principal_staking_totals.bond_claimed_rewards + EXCLUDED.bond_claimed_rewards + `; + } + + /** + * Record a per-signer reward claim aggregate (pox-5 `claim-rewards`). This is + * audit/bookkeeping only — the per-staker effects are handled by the + * `claim-staker-rewards-for-signer` events — so no running totals are touched. + */ + private async updateSignerRewardClaim( + sql: PgSqlClient, + txLocation: DbTxLocation, + event: Pox5EventClaimRewards + ) { + const claim: DbSignerRewardClaimInsertValues = { + ...txLocation, + signer_manager: event.data.signer_manager, + reward_cycle: parseInt(event.data.reward_cycle), + stx_earned: event.data.stx_rewards.earned, + stx_rewards_per_token: event.data.stx_rewards.rewards_per_token, + bond_rewards: JSON.stringify(event.data.bond_rewards), + bond_totals: event.data.bond_totals, + total_rewards: event.data.total_rewards, + }; + await sql` + INSERT INTO signer_reward_claims ${sql(claim)} + `; } /** Cycle-level reward aggregate, from the pox-5 `calculate-rewards` event. */ @@ -927,6 +1037,42 @@ export class PgWriteStore extends PgStore { await sql` INSERT INTO bond_reward_calculations ${sql(rewardCalculation)} `; + + // Split the STX-staker reward pool across the current pox-5 STX lockers by + // their locked weight: each staker accrues `floor(locked_ustx * per_ustx / + // PRECISION)` (PRECISION = 1e18). The per-uSTX rate is uniform, so this is + // the staker's share of `total_stx_staker_rewards` (modulo rounding dust). + const perUstx = event.data.accrued_rewards_per_ustx; + const rewardCycle = parseInt(event.data.stx_cycle); + const stakerRewards = await sql<{ principal: string; reward_amount: string }[]>` + SELECT principal, + floor(locked_amount::numeric * ${perUstx}::numeric / 1000000000000000000)::text AS reward_amount + FROM stx_locked_balances + WHERE pox_version = 5 + AND floor(locked_amount::numeric * ${perUstx}::numeric / 1000000000000000000) > 0 + `; + if (stakerRewards.length === 0) { + return; + } + const rewardRows: DbPrincipalStxRewardDistributionInsertValues[] = stakerRewards.map(s => ({ + ...txLocation, + principal: s.principal, + reward_cycle: rewardCycle, + reward_amount: s.reward_amount, + })); + for (const batch of batchIterate(rewardRows, INSERT_BATCH_SIZE)) { + await sql` + INSERT INTO principal_stx_reward_distributions ${sql(batch)} + `; + } + for (const s of stakerRewards) { + await sql` + INSERT INTO principal_staking_totals (principal, stx_accrued_rewards) + VALUES (${s.principal}, ${s.reward_amount}::numeric) + ON CONFLICT (principal) DO UPDATE + SET stx_accrued_rewards = principal_staking_totals.stx_accrued_rewards + EXCLUDED.stx_accrued_rewards + `; + } } /** @@ -4336,6 +4482,7 @@ export class PgWriteStore extends PgStore { 'bonds', 'bond_reward_distributions', 'bond_reward_calculations', + 'signer_reward_claims', ]) { q.enqueue(async () => { await sql` @@ -4399,22 +4546,40 @@ export class PgWriteStore extends PgStore { UPDATE principal_bond_positions SET canonical = ${canonical} WHERE index_block_hash = ${indexBlockHash} AND canonical != ${canonical} - RETURNING bond_index, btc_locked, stx_locked, btc_paid_out, canonical + RETURNING principal, bond_index, btc_locked, stx_locked, btc_paid_out, canonical ), - changes AS ( + bond_changes AS ( SELECT bond_index, SUM(CASE WHEN canonical THEN btc_locked::numeric ELSE -btc_locked::numeric END) AS btc_change, SUM(CASE WHEN canonical THEN stx_locked::numeric ELSE -stx_locked::numeric END) AS stx_change, SUM(CASE WHEN canonical THEN btc_paid_out::numeric ELSE -btc_paid_out::numeric END) AS paid_change FROM updated GROUP BY bond_index + ), + bond_update AS ( + UPDATE bonds AS b + SET btc_locked = b.btc_locked + c.btc_change, + stx_locked = b.stx_locked + c.stx_change, + btc_paid_out = b.btc_paid_out + c.paid_change + FROM bond_changes c + WHERE b.bond_index = c.bond_index + RETURNING 1 + ), + principal_changes AS ( + SELECT principal, + SUM(CASE WHEN canonical THEN 1 ELSE -1 END) AS count_change, + SUM(CASE WHEN canonical THEN btc_locked::numeric ELSE -btc_locked::numeric END) AS btc_change, + SUM(CASE WHEN canonical THEN stx_locked::numeric ELSE -stx_locked::numeric END) AS stx_change + FROM updated + GROUP BY principal ) - UPDATE bonds AS b - SET btc_locked = b.btc_locked + c.btc_change, - stx_locked = b.stx_locked + c.stx_change, - btc_paid_out = b.btc_paid_out + c.paid_change - FROM changes c - WHERE b.bond_index = c.bond_index + INSERT INTO principal_staking_totals + (principal, bond_count, bond_btc_locked, bond_stx_locked) + SELECT principal, count_change, btc_change, stx_change FROM principal_changes + ON CONFLICT (principal) DO UPDATE SET + bond_count = principal_staking_totals.bond_count + EXCLUDED.bond_count, + bond_btc_locked = principal_staking_totals.bond_btc_locked + EXCLUDED.bond_btc_locked, + bond_stx_locked = principal_staking_totals.bond_stx_locked + EXCLUDED.bond_stx_locked `; }); q.enqueue(async () => { @@ -4432,11 +4597,99 @@ export class PgWriteStore extends PgStore { SUM(CASE WHEN canonical THEN reward_amount::numeric ELSE -reward_amount::numeric END) AS reward_change FROM updated GROUP BY principal, bond_index + ), + pos_update AS ( + 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 + RETURNING 1 + ), + principal_changes AS ( + SELECT principal, SUM(reward_change) AS reward_change + FROM changes + GROUP BY principal + ) + INSERT INTO principal_staking_totals (principal, bond_accrued_rewards) + SELECT principal, reward_change FROM principal_changes + ON CONFLICT (principal) DO UPDATE SET + bond_accrued_rewards = principal_staking_totals.bond_accrued_rewards + EXCLUDED.bond_accrued_rewards + `; + }); + q.enqueue(async () => { + // Flip the per-staker reward claim source rows and apply the signed delta + // to each bond position's running claimed_rewards total (bond claims). + await sql` + WITH updated AS ( + UPDATE principal_bond_reward_claims + SET canonical = ${canonical} + WHERE index_block_hash = ${indexBlockHash} AND canonical != ${canonical} + RETURNING principal, bond_index, rewards_claimed, canonical + ), + changes AS ( + SELECT principal, bond_index, + SUM(CASE WHEN canonical THEN rewards_claimed::numeric ELSE -rewards_claimed::numeric END) AS claim_change + FROM updated + WHERE bond_index IS NOT NULL + GROUP BY principal, bond_index + ), + pos_update AS ( + UPDATE principal_bond_positions p + SET claimed_rewards = p.claimed_rewards + c.claim_change + FROM changes c + WHERE p.principal = c.principal AND p.bond_index = c.bond_index + RETURNING 1 + ), + principal_changes AS ( + SELECT principal, SUM(claim_change) AS claim_change + FROM changes + GROUP BY principal + ) + INSERT INTO principal_staking_totals (principal, bond_claimed_rewards) + SELECT principal, claim_change FROM principal_changes + ON CONFLICT (principal) DO UPDATE SET + bond_claimed_rewards = principal_staking_totals.bond_claimed_rewards + EXCLUDED.bond_claimed_rewards + `; + // The flip above also settled the STX-staking claims (NULL bond_index); + // apply their signed delta to the STX-staking claimed total. The rows are + // now at `canonical`, so the direction follows that flag. + await sql` + WITH changes AS ( + SELECT principal, SUM(rewards_claimed::numeric) AS total + FROM principal_bond_reward_claims + WHERE index_block_hash = ${indexBlockHash} + AND canonical = ${canonical} + AND bond_index IS NULL + GROUP BY principal + ) + UPDATE principal_staking_totals p + SET stx_claimed_rewards = ${ + canonical ? sql`p.stx_claimed_rewards + c.total` : sql`p.stx_claimed_rewards - c.total` + } + FROM changes c + WHERE p.principal = c.principal + `; + }); + q.enqueue(async () => { + // Flip the per-staker STX reward distribution source rows and apply the + // signed delta to each staker's running STX-staking accrued_rewards total. + await sql` + WITH updated AS ( + UPDATE principal_stx_reward_distributions + SET canonical = ${canonical} + WHERE index_block_hash = ${indexBlockHash} AND canonical != ${canonical} + RETURNING principal, reward_amount, canonical + ), + changes AS ( + SELECT principal, + SUM(CASE WHEN canonical THEN reward_amount::numeric ELSE -reward_amount::numeric END) AS reward_change + FROM updated + GROUP BY principal ) - UPDATE principal_bond_positions p - SET accrued_rewards = p.accrued_rewards + c.reward_change + UPDATE principal_staking_totals p + SET stx_accrued_rewards = p.stx_accrued_rewards + c.reward_change FROM changes c - WHERE p.principal = c.principal AND p.bond_index = c.bond_index + WHERE p.principal = c.principal `; }); q.enqueue(async () => { diff --git a/src/datastore/v3/constants.ts b/src/datastore/v3/constants.ts index f0df14f08..5e4d97b1e 100644 --- a/src/datastore/v3/constants.ts +++ b/src/datastore/v3/constants.ts @@ -155,5 +155,6 @@ export const PRINCIPAL_BOND_POSITION_COLUMNS = [ 'stx_locked', 'btc_paid_out', 'accrued_rewards', + 'claimed_rewards', 'tx_id', ]; diff --git a/src/datastore/v3/pg-store-v3.ts b/src/datastore/v3/pg-store-v3.ts index 4428df757..4fde08fde 100644 --- a/src/datastore/v3/pg-store-v3.ts +++ b/src/datastore/v3/pg-store-v3.ts @@ -11,6 +11,7 @@ import { DbPrincipalBondPosition, DbPrincipalFtBalance, DbPrincipalNftBalance, + DbPrincipalStakingSummary, DbPrincipalTransactionSummary, DbTransaction, DbTransactionCursor, @@ -29,7 +30,7 @@ import { TX_COLUMNS, TX_SUMMARY_COLUMNS, } from './constants.js'; -import { prefixedCols } from '../helpers.js'; +import { MaterializedStxLockRow, prefixedCols, resolveMaterializedStxLock } from '../helpers.js'; import { Principal } from '../../api/schemas/v3/entities/common.js'; import { normalizeHashString } from '../../helpers.js'; import { BlockIdParam } from '../../api/routes/v2/schemas.js'; @@ -1159,22 +1160,126 @@ export class PgStoreV3 extends BasePgStoreModule { } /** - * Gets all bond positions for a principal (across the bonds it is enrolled in). + * One-call staking overview for a principal: its (singleton) pox-5 STX-staking + * position plus aggregate totals across all of its bond positions. + * @param args - The arguments for the query. + * @returns The staking summary. + */ + async getPrincipalStakingSummary(args: { + principal: Principal; + }): Promise { + return await this.sqlTransaction(async sql => { + // The pox-5 STX staking lock (latest-wins materialized row), resolved + // against the current burn tip so an expired-but-not-unstaked lock reads + // as 0 — consistent with `/balances/stx`. pox-5 has no force-unlock + // height, so only natural expiry applies (forceUnlockHeights = null). + const [tip] = await sql<{ burn_block_height: number }[]>` + SELECT burn_block_height FROM chain_tip + `; + const [lockRow] = await sql` + SELECT locked_amount, unlock_burn_height, pox_version, lock_tx_id, + lock_block_height, burnchain_lock_height + FROM stx_locked_balances + WHERE principal = ${args.principal} AND pox_version = 5 + LIMIT 1 + `; + const resolvedLock = resolveMaterializedStxLock(lockRow, tip?.burn_block_height ?? 0, null); + // The staking summary is materialized — a single-row lookup, no aggregates. + const [totals] = await sql< + { + stx_accrued_rewards: string; + stx_claimed_rewards: string; + bond_count: number; + bond_btc_locked: string; + bond_stx_locked: string; + bond_accrued_rewards: string; + bond_claimed_rewards: string; + }[] + >` + SELECT stx_accrued_rewards, stx_claimed_rewards, bond_count, + bond_btc_locked, bond_stx_locked, bond_accrued_rewards, bond_claimed_rewards + FROM principal_staking_totals + WHERE principal = ${args.principal} + LIMIT 1 + `; + return { + stx: { + locked: resolvedLock.locked.toString(), + accrued_rewards: totals?.stx_accrued_rewards ?? '0', + claimed_rewards: totals?.stx_claimed_rewards ?? '0', + }, + bonds: { + count: totals?.bond_count ?? 0, + btc_locked: totals?.bond_btc_locked ?? '0', + stx_locked: totals?.bond_stx_locked ?? '0', + accrued_rewards: totals?.bond_accrued_rewards ?? '0', + claimed_rewards: totals?.bond_claimed_rewards ?? '0', + }, + }; + }); + } + + /** + * Gets a principal's bond positions, cursor-paginated by `bond_index` ascending. * @param args - The arguments for the query. * @returns The principal's bond positions. */ - async getPrincipalStakingPositions(args: { + async getPrincipalBondPositions(args: { principal: Principal; - }): Promise { + limit: number; + cursor?: BondCursor; + }): Promise> { return await this.sqlTransaction(async sql => { - return await sql` - SELECT ${sql(PRINCIPAL_BOND_POSITION_COLUMNS)} - FROM principal_bond_positions - WHERE canonical = true - AND microblock_canonical = true - AND principal = ${args.principal} - ORDER BY bond_index ASC + // The position count is materialized on `principal_staking_totals` — a + // single-row lookup rather than a COUNT over `principal_bond_positions`. + const [totals] = await sql<{ bond_count: number }[]>` + SELECT bond_count FROM principal_staking_totals WHERE principal = ${args.principal} LIMIT 1 + `; + const total = totals?.bond_count ?? 0; + + const cursorFilter = args.cursor ? sql`AND bond_index >= ${parseInt(args.cursor)}` : sql``; + const resultQuery = await sql` + SELECT ${sql(prefixedCols(PRINCIPAL_BOND_POSITION_COLUMNS, 'p'))} + FROM principal_bond_positions p + WHERE p.canonical = true + AND p.microblock_canonical = true + AND p.principal = ${args.principal} + ${cursorFilter} + ORDER BY p.bond_index ASC + LIMIT ${args.limit + 1} `; + + const hasNextPage = resultQuery.count > args.limit; + const results = hasNextPage ? resultQuery.slice(0, args.limit) : resultQuery; + + const nextResult = resultQuery[resultQuery.length - 1]; + const nextCursor = hasNextPage && nextResult ? `${nextResult.bond_index}` : null; + const firstResult = results[0]; + const currentCursor = firstResult ? `${firstResult.bond_index}` : null; + + let prevCursor: string | null = null; + if (firstResult) { + const prevPageQuery = await sql<{ bond_index: number }[]>` + SELECT bond_index FROM principal_bond_positions + WHERE canonical = true AND microblock_canonical = true + AND principal = ${args.principal} + AND bond_index < ${firstResult.bond_index} + ORDER BY bond_index DESC + LIMIT ${args.limit} + `; + if (prevPageQuery.length > 0) { + prevCursor = `${prevPageQuery[prevPageQuery.length - 1].bond_index}`; + } + } + + return { + limit: args.limit, + next_cursor: nextCursor, + prev_cursor: prevCursor, + current_cursor: currentCursor, + total, + results, + }; }); } } diff --git a/src/datastore/v3/types.ts b/src/datastore/v3/types.ts index 66f68ec6d..ff00c449d 100644 --- a/src/datastore/v3/types.ts +++ b/src/datastore/v3/types.ts @@ -193,9 +193,33 @@ export interface DbPrincipalBondPosition { stx_locked: string; btc_paid_out: string; accrued_rewards: string; + claimed_rewards: string; tx_id: string; } +/** A principal's pox-5 STX-staking position: locked uSTX plus running sBTC reward totals. */ +export interface DbPrincipalStxStaking { + /** Current uSTX locked in pox-5 STX staking. */ + locked: string; + accrued_rewards: string; + claimed_rewards: string; +} + +/** Aggregate of a principal's bond positions (counts + summed locks/rewards). */ +export interface DbPrincipalBondStakingAggregate { + count: number; + btc_locked: string; + stx_locked: string; + accrued_rewards: string; + claimed_rewards: string; +} + +/** One-call staking overview: the STX-staking position plus the bond aggregate. */ +export interface DbPrincipalStakingSummary { + stx: DbPrincipalStxStaking; + bonds: DbPrincipalBondStakingAggregate; +} + export interface DbTransactionCursor { block_height: number; microblock_sequence: number; diff --git a/tests/api/pox5/bonds.test.ts b/tests/api/pox5/bonds.test.ts index 475866b35..fd111e64c 100644 --- a/tests/api/pox5/bonds.test.ts +++ b/tests/api/pox5/bonds.test.ts @@ -91,14 +91,26 @@ interface CursorPaginated { total: number; results: T[]; } +interface BtcRewardsItem { + accrued: string; + claimed: string; + claimable: string; +} interface PrincipalBondPositionItem { bond_index: number; status: string; active: boolean; - balances: { locked: { btc: string; stx: string }; paid_out: { btc: string } }; enrollment: { tx_id: string; btc_lockup: { amount: string } }; - amount: string; - accrued_rewards: string; + locked: { btc: string; stx: string }; + rewards: { btc: BtcRewardsItem }; +} +interface BondPositionsPage { + total: number; + results: PrincipalBondPositionItem[]; +} +interface StakingSummary { + stx: { locked: string; rewards: { btc: BtcRewardsItem } }; + bonds: { count: number; locked: { btc: string; stx: string }; rewards: { btc: BtcRewardsItem } }; } const normalizeTxId = (txid: string) => txid.replace(/^0x/, '').toLowerCase(); @@ -302,19 +314,18 @@ describe('pox-5 bonds (simulated ingestion)', () => { }); }); - test("alice's position appears in GET .../principals/:principal/balances/staking", async () => { - const positions = await getJson( - `/extended/v3/principals/${ALICE}/balances/staking` + test("alice's position appears in GET .../principals/:principal/staking/bonds", async () => { + const page = await getJson( + `/extended/v3/principals/${ALICE}/staking/bonds` ); - const pos = positions.find(p => p.bond_index === BOND_INDEX); + const pos = page.results.find(p => p.bond_index === BOND_INDEX); assert.ok(pos, `alice has a position for bond #${BOND_INDEX}`); assert.equal(pos.status, 'enrolled'); assert.equal(pos.active, true); // locked STX = registered amount_ustx; locked BTC = registered sats_total. - assert.equal(BigInt(pos.balances.locked.stx), AMOUNT_USTX); - assert.equal(BigInt(pos.balances.locked.btc), SBTC_SATS); - assert.equal(BigInt(pos.balances.paid_out.btc), 0n); - assert.equal(BigInt(pos.amount), AMOUNT_USTX); + assert.equal(BigInt(pos.locked.stx), AMOUNT_USTX); + assert.equal(BigInt(pos.locked.btc), SBTC_SATS); + assert.equal(BigInt(pos.rewards.btc.accrued), 0n); assert.equal(BigInt(pos.enrollment.btc_lockup.amount), SBTC_SATS); // The position links back to the register-for-bond transaction. assert.equal(normalizeTxId(pos.enrollment.tx_id), normalizeTxId(REGISTER_TX_ID)); @@ -554,8 +565,8 @@ 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/principals/${ALICE}/balances/staking`); - assert.equal(positions.length, 1, 'position visible on fork A'); + const summary = await getJson(`/extended/v3/principals/${ALICE}/staking`); + assert.equal(summary.bonds.count, 1, 'position visible on fork A'); // Fork B overtakes fork A (height 2 then 3) — no bond on this fork. await db.update( @@ -585,8 +596,8 @@ 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/principals/${ALICE}/balances/staking`); - assert.equal(positionsAfter.length, 0, 'position gone after reorg'); + const summaryAfter = await getJson(`/extended/v3/principals/${ALICE}/staking`); + assert.equal(summaryAfter.bonds.count, 0, 'position gone after reorg'); // Fork A wins again (extends to height 4) — the bond is restored. await db.update( @@ -614,10 +625,10 @@ describe('pox-5 bonds reorg handling', () => { `/extended/v3/staking/bonds/${BOND_INDEX}/registrations?limit=50` ); assert.equal(regsRestored.results.length, 1, 'registration restored'); - const positionsRestored = await getJson( - `/extended/v3/principals/${ALICE}/balances/staking` + const summaryRestored = await getJson( + `/extended/v3/principals/${ALICE}/staking` ); - assert.equal(positionsRestored.length, 1, 'position restored'); + assert.equal(summaryRestored.bonds.count, 1, 'position restored'); }); test('a partial reorg of only the registration block reverts the registration counters but keeps the bond', async () => { @@ -724,10 +735,8 @@ 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/principals/${ALICE}/balances/staking` - ); - assert.equal(positions.length, 0, 'position orphaned'); + const summary = await getJson(`/extended/v3/principals/${ALICE}/staking`); + assert.equal(summary.bonds.count, 0, 'position orphaned'); }); }); @@ -754,8 +763,8 @@ describe('pox-5 bonds unstake / early-exit', () => { assert.ok(pos, `alice has a position for bond #${BOND_INDEX}`); return pos; } - const getPositions = () => - getJson(`/extended/v3/principals/${ALICE}/balances/staking`); + const getPositions = async () => + (await getJson(`/extended/v3/principals/${ALICE}/staking/bonds`)).results; const getBond = () => getJson(`/extended/v3/staking/bonds/${BOND_INDEX}`); beforeEach(async () => { @@ -802,7 +811,7 @@ describe('pox-5 bonds unstake / early-exit', () => { const pos = alicePosition(await getPositions()); assert.equal(pos.status, 'enrolled'); assert.equal(pos.active, true); - assert.equal(BigInt(pos.balances.locked.btc), SBTC_SATS); + assert.equal(BigInt(pos.locked.btc), SBTC_SATS); const bond = await getBond(); assert.equal(BigInt(bond.balances.locked.btc), SBTC_SATS); }); @@ -829,7 +838,7 @@ describe('pox-5 bonds unstake / early-exit', () => { .build() ); let pos = alicePosition(await getPositions()); - assert.equal(BigInt(pos.balances.locked.btc), UNSTAKE_PARTIAL_SATS, 'position sBTC reduced'); + assert.equal(BigInt(pos.locked.btc), UNSTAKE_PARTIAL_SATS, 'position sBTC reduced'); assert.equal(pos.status, 'enrolled', 'still enrolled on a partial unstake'); assert.equal(BigInt((await getBond()).balances.locked.btc), UNSTAKE_PARTIAL_SATS, 'bond btc_locked reduced'); @@ -850,7 +859,7 @@ describe('pox-5 bonds unstake / early-exit', () => { .build() ); pos = alicePosition(await getPositions()); - assert.equal(BigInt(pos.balances.locked.btc), 0n, 'position sBTC cleared'); + assert.equal(BigInt(pos.locked.btc), 0n, 'position sBTC cleared'); assert.equal(pos.status, 'early_exit', 'marked early_exit on full unstake'); assert.equal(BigInt((await getBond()).balances.locked.btc), 0n, 'bond btc_locked cleared'); }); @@ -875,7 +884,7 @@ describe('pox-5 bonds unstake / early-exit', () => { assert.equal(pos.status, 'early_exit', 'status -> early_exit'); assert.equal(pos.active, false, 'position deactivated'); // Announcing an exit does not move funds; locked balances are unchanged. - assert.equal(BigInt(pos.balances.locked.btc), SBTC_SATS, 'locked sBTC unchanged'); + assert.equal(BigInt(pos.locked.btc), SBTC_SATS, 'locked sBTC unchanged'); assert.equal(BigInt((await getBond()).balances.locked.btc), SBTC_SATS, 'bond btc_locked unchanged'); }); }); @@ -903,13 +912,22 @@ describe('pox-5 bonds reward accrual', () => { 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` + async function rewardsFor( + principal: string + ): Promise<{ accrued: bigint; claimed: bigint; claimable: bigint }> { + const page = await getJson( + `/extended/v3/principals/${principal}/staking/bonds` ); - const pos = positions.find(p => p.bond_index === BOND_INDEX); + const pos = page.results.find(p => p.bond_index === BOND_INDEX); assert.ok(pos, `position for ${principal}`); - return BigInt(pos.accrued_rewards); + return { + accrued: BigInt(pos.rewards.btc.accrued), + claimed: BigInt(pos.rewards.btc.claimed), + claimable: BigInt(pos.rewards.btc.claimable), + }; + } + async function accruedFor(principal: string): Promise { + return (await rewardsFor(principal)).accrued; } function registerEvent(staker: string, sats: bigint) { return { @@ -1037,4 +1055,650 @@ describe('pox-5 bonds reward accrual', () => { assert.equal(await accruedFor(ALICE), 0n, 'alice accrual reverted'); assert.equal(await accruedFor(BOB), 0n, 'bob accrual reverted'); }); + + const CLAIM_TX_ID = '0x' + 'c1'.repeat(32); + const ALICE_CLAIM = 1_500n; + function claimBlock(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: CLAIM_TX_ID }) + .addTxPox5Event({ + name: Pox5EventName.ClaimStakerRewardsForSigner, + data: { + signer_manager: SIGNER, + staker: ALICE, + reward_cycle: String(FIRST_REWARD_CYCLE), + bond_index: String(BOND_INDEX), + rewards_claimed: ALICE_CLAIM.toString(), + }, + }) + .addTxPox5Event({ + // An STX-staking claim (no bond) for bob: recorded as a claim row but + // must NOT touch his bond position. + name: Pox5EventName.ClaimStakerRewardsForSigner, + data: { + signer_manager: SIGNER, + staker: BOB, + reward_cycle: String(FIRST_REWARD_CYCLE), + bond_index: null, + rewards_claimed: '999', + }, + }) + .build(); + } + + test('a claim rolls into the position claimed total and reduces claimable', async () => { + await db.update( + distributionBlock({ + block_height: 2, + block_hash: '0x02', + index_block_hash: '0x02', + parent_block_hash: '0x01', + parent_index_block_hash: '0x01', + }) + ); + await db.update( + claimBlock({ + block_height: 3, + block_hash: '0x03', + index_block_hash: '0x03', + parent_block_hash: '0x02', + parent_index_block_hash: '0x02', + }) + ); + + const alice = await rewardsFor(ALICE); + assert.equal(alice.accrued, ALICE_EXPECTED, 'accrued untouched by the claim'); + assert.equal(alice.claimed, ALICE_CLAIM); + assert.equal(alice.claimable, ALICE_EXPECTED - ALICE_CLAIM); + + // Bob's claim was an STX-staking claim (null bond_index): his bond position + // is unaffected, but the claim row exists. + const bob = await rewardsFor(BOB); + assert.equal(bob.accrued, BOB_EXPECTED); + assert.equal(bob.claimed, 0n); + assert.equal(bob.claimable, BOB_EXPECTED); + const bobClaims = await db.sql<{ bond_index: number | null; rewards_claimed: string }[]>` + SELECT bond_index, rewards_claimed FROM principal_bond_reward_claims + WHERE principal = ${BOB} AND canonical = true + `; + assert.equal(bobClaims.length, 1); + assert.equal(bobClaims[0].bond_index, null); + assert.equal(bobClaims[0].rewards_claimed, '999'); + }); + + test('orphaning the claim block reverts the claimed total', async () => { + await db.update( + distributionBlock({ + block_height: 2, + block_hash: '0x02', + index_block_hash: '0x02', + parent_block_hash: '0x01', + parent_index_block_hash: '0x01', + }) + ); + await db.update( + claimBlock({ + block_height: 3, + block_hash: '0x03', + index_block_hash: '0x03', + parent_block_hash: '0x02', + parent_index_block_hash: '0x02', + }) + ); + assert.equal((await rewardsFor(ALICE)).claimed, ALICE_CLAIM); + + // Fork B branches from the distribution block and overtakes, orphaning ONLY + // the claim block — accrual survives, the claim reverts. + await db.update( + new TestBlockBuilder({ + block_height: 3, + block_hash: '0xb3', + index_block_hash: '0xb3', + parent_block_hash: '0x02', + parent_index_block_hash: '0x02', + }).build() + ); + await db.update( + new TestBlockBuilder({ + block_height: 4, + block_hash: '0xb4', + index_block_hash: '0xb4', + parent_block_hash: '0xb3', + parent_index_block_hash: '0xb3', + }).build() + ); + + const alice = await rewardsFor(ALICE); + assert.equal(alice.accrued, ALICE_EXPECTED, 'accrual survives the claim reorg'); + assert.equal(alice.claimed, 0n, 'claim reverted'); + assert.equal(alice.claimable, ALICE_EXPECTED); + }); + + test('the materialized summary aggregate tracks distribute, claim, and reorg', async () => { + const summaryFor = (principal: string) => + getJson(`/extended/v3/principals/${principal}/staking`); + + await db.update( + distributionBlock({ + block_height: 2, + block_hash: '0x02', + index_block_hash: '0x02', + parent_block_hash: '0x01', + parent_index_block_hash: '0x01', + }) + ); + let summary = await summaryFor(ALICE); + assert.equal(summary.bonds.count, 1); + assert.equal(BigInt(summary.bonds.locked.btc), ALICE_SATS, 'aggregate locked btc'); + assert.equal(BigInt(summary.bonds.locked.stx), AMOUNT_USTX, 'aggregate locked stx'); + assert.equal(BigInt(summary.bonds.rewards.btc.accrued), ALICE_EXPECTED, 'aggregate accrued'); + + await db.update( + claimBlock({ + block_height: 3, + block_hash: '0x03', + index_block_hash: '0x03', + parent_block_hash: '0x02', + parent_index_block_hash: '0x02', + }) + ); + summary = await summaryFor(ALICE); + assert.equal(BigInt(summary.bonds.rewards.btc.claimed), ALICE_CLAIM, 'aggregate claimed'); + assert.equal( + BigInt(summary.bonds.rewards.btc.claimable), + ALICE_EXPECTED - ALICE_CLAIM, + 'aggregate claimable' + ); + + // Fork B branches from the seed (block 1) and overtakes, orphaning the + // distribution and claim blocks. The position survives; accrued + claimed revert. + 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() + ); + await db.update( + new TestBlockBuilder({ + block_height: 4, + block_hash: '0xb4', + index_block_hash: '0xb4', + parent_block_hash: '0xb3', + parent_index_block_hash: '0xb3', + }).build() + ); + summary = await summaryFor(ALICE); + assert.equal(summary.bonds.count, 1, 'position survives the reorg'); + assert.equal(BigInt(summary.bonds.locked.btc), ALICE_SATS, 'locked unchanged'); + assert.equal(BigInt(summary.bonds.rewards.btc.accrued), 0n, 'accrued reverted'); + assert.equal(BigInt(summary.bonds.rewards.btc.claimed), 0n, 'claimed reverted'); + }); +}); + +/** + * STX-staking reward accrual: each pox-5 `calculate-rewards` event allocates an + * sBTC pool to STX stackers at a uniform per-uSTX rate; it is split across the + * current pox-5 STX lockers by their locked weight, accrued onto a per-staker + * running total, and claimed via `claim-staker-rewards-for-signer` events with + * a NULL bond_index. All of it is reorg-safe. + */ +describe('pox-5 STX-staking reward accrual', () => { + let db: PgWriteStore; + let api: ApiServer; + + const CALC_TX_ID = '0x' + 'ca'.repeat(32); + const STX_CLAIM_TX_ID = '0x' + 'cb'.repeat(32); + const ALICE_USTX = 1_000n; + const BOB_USTX = 4_000n; + // 2 reward sats per staked uSTX (PRECISION = 1e18). + const PER_USTX = (2n * 1_000_000_000_000_000_000n).toString(); + const ALICE_EXPECTED = ALICE_USTX * 2n; // 2000 + const BOB_EXPECTED = BOB_USTX * 2n; // 8000 + const ALICE_CLAIM = 1_500n; + const STX_CYCLE = 8; + + 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 stxRewardsFor(principal: string) { + const summary = await getJson( + `/extended/v3/principals/${principal}/staking` + ); + return { + locked: BigInt(summary.stx.locked), + accrued: BigInt(summary.stx.rewards.btc.accrued), + claimed: BigInt(summary.stx.rewards.btc.claimed), + claimable: BigInt(summary.stx.rewards.btc.claimable), + }; + } + function stakeData(staker: string, ustx: bigint) { + return { + signer: SIGNER, + staker, + amount_ustx: ustx.toString(), + num_cycles: '2', + first_reward_cycle: String(STX_CYCLE), + // Above the TestBlockBuilder default burn height (713000) so the lock is + // still active at the chain tip and resolves as locked (not expired). + unlock_burn_height: '1000000', + unlock_cycle: '20', + }; + } + function calculateRewardsBlock(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: CALC_TX_ID }) + .addTxPox5Event({ + name: Pox5EventName.CalculateRewards, + data: { + bond_periods: [], + calculation_height: '200', + gross_accrued_rewards: ((ALICE_USTX + BOB_USTX) * 2n).toString(), + total_bond_rewards: '0', + reserve_deposit: '0', + reserve_balance: '0', + stx_cycle: String(STX_CYCLE), + total_stx_staker_rewards: ((ALICE_USTX + BOB_USTX) * 2n).toString(), + cycle_staked_ustx: (ALICE_USTX + BOB_USTX).toString(), + accrued_rewards_per_ustx: PER_USTX, + cumulative_rewards_per_ustx: PER_USTX, + }, + }) + .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 }); + + // Block 1: alice + bob stake STX at different weights (pox-5 locks). + await db.update( + new TestBlockBuilder({ block_height: 1, block_hash: '0x01', index_block_hash: '0x01' }) + .addTx({ tx_id: '0x' + 'e1'.repeat(32) }) + .addTxPox5Event({ name: Pox5EventName.Stake, data: stakeData(ALICE, ALICE_USTX) }) + .addTx({ tx_id: '0x' + 'e2'.repeat(32) }) + .addTxPox5Event({ name: Pox5EventName.Stake, data: stakeData(BOB, BOB_USTX) }) + .build() + ); + }); + + afterEach(async () => { + await api.terminate(); + await db?.close(); + await migrate('down'); + }); + + test('calculate-rewards accrues STX-staking rewards by locked weight', async () => { + // Staked, no rewards yet. + let alice = await stxRewardsFor(ALICE); + assert.equal(alice.locked, ALICE_USTX); + assert.equal(alice.accrued, 0n); + + await db.update( + calculateRewardsBlock({ + block_height: 2, + block_hash: '0x02', + index_block_hash: '0x02', + parent_block_hash: '0x01', + parent_index_block_hash: '0x01', + }) + ); + + alice = await stxRewardsFor(ALICE); + const bob = await stxRewardsFor(BOB); + assert.equal(alice.accrued, ALICE_EXPECTED); + assert.equal(alice.claimable, ALICE_EXPECTED); + assert.equal(bob.accrued, BOB_EXPECTED); + // No bond positions for either staker. + const summary = await getJson(`/extended/v3/principals/${ALICE}/staking`); + assert.equal(summary.bonds.count, 0, 'STX staking has no bond positions'); + }); + + test('an expired pox-5 lock resolves to zero locked STX', async () => { + const CAROL = 'ST1SJ3DTE5DN7X54YDH5D64R3BCB6A2AG2ZQ8YPD5'; + // Stake with an unlock height below the chain-tip burn height (the + // TestBlockBuilder default, 713000), so the lock is already expired. + await db.update( + new TestBlockBuilder({ + block_height: 2, + block_hash: '0x02', + index_block_hash: '0x02', + parent_block_hash: '0x01', + parent_index_block_hash: '0x01', + }) + .addTx({ tx_id: '0x' + 'ee'.repeat(32) }) + .addTxPox5Event({ + name: Pox5EventName.Stake, + data: { ...stakeData(CAROL, 1_000n), unlock_burn_height: '100' }, + }) + .build() + ); + // The materialized lock row exists, but it has expired — so `locked` reads + // as 0, consistent with /balances/stx. + const carol = await stxRewardsFor(CAROL); + assert.equal(carol.locked, 0n, 'expired lock reads as zero locked'); + }); + + test('an STX-staking claim rolls into claimed and reduces claimable', async () => { + await db.update( + calculateRewardsBlock({ + block_height: 2, + block_hash: '0x02', + index_block_hash: '0x02', + parent_block_hash: '0x01', + parent_index_block_hash: '0x01', + }) + ); + await db.update( + new TestBlockBuilder({ + block_height: 3, + block_hash: '0x03', + index_block_hash: '0x03', + parent_block_hash: '0x02', + parent_index_block_hash: '0x02', + }) + .addTx({ tx_id: STX_CLAIM_TX_ID }) + .addTxPox5Event({ + name: Pox5EventName.ClaimStakerRewardsForSigner, + data: { + signer_manager: SIGNER, + staker: ALICE, + reward_cycle: String(STX_CYCLE), + bond_index: null, + rewards_claimed: ALICE_CLAIM.toString(), + }, + }) + .build() + ); + + const alice = await stxRewardsFor(ALICE); + assert.equal(alice.accrued, ALICE_EXPECTED, 'accrued untouched by the claim'); + assert.equal(alice.claimed, ALICE_CLAIM); + assert.equal(alice.claimable, ALICE_EXPECTED - ALICE_CLAIM); + }); + + test('orphaning the calculate-rewards block reverts the STX accrual', async () => { + await db.update( + calculateRewardsBlock({ + block_height: 2, + block_hash: '0x02', + index_block_hash: '0x02', + parent_block_hash: '0x01', + parent_index_block_hash: '0x01', + }) + ); + assert.equal((await stxRewardsFor(ALICE)).accrued, ALICE_EXPECTED); + + // Fork B branches from the stake block (block 1) and overtakes, orphaning + // the calculate-rewards block — the stakes 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() + ); + + const alice = await stxRewardsFor(ALICE); + assert.equal(alice.locked, ALICE_USTX, 'stake survives'); + assert.equal(alice.accrued, 0n, 'STX accrual reverted'); + assert.equal(alice.claimable, 0n); + }); +}); + +/** + * Per-signer reward claim aggregate (pox-5 `claim-rewards`): recorded as + * audit/bookkeeping in `signer_reward_claims` with no running-total math; reorgs + * only flip its canonical flag. + */ +describe('pox-5 signer reward claims', () => { + let db: PgWriteStore; + let api: ApiServer; + + const CLAIM_TX_ID = '0x' + 'f1'.repeat(32); + const REWARD_CYCLE = 8; + + const claimData = { + signer_manager: SIGNER, + reward_cycle: String(REWARD_CYCLE), + stx_rewards: { earned: '2000', rewards_per_token: '100' }, + bond_rewards: [ + { bond_index: '0', earned: '8000', rewards_per_token: '200' }, + { bond_index: '1', earned: '5000', rewards_per_token: '150' }, + ], + bond_totals: '13000', + total_rewards: '15000', + }; + + async function claimRow() { + const rows = await db.sql< + { + signer_manager: string; + reward_cycle: number; + stx_earned: string; + stx_rewards_per_token: string; + bond_rewards: string; + bond_totals: string; + total_rewards: string; + canonical: boolean; + }[] + >` + SELECT signer_manager, reward_cycle, stx_earned, stx_rewards_per_token, + bond_rewards, bond_totals, total_rewards, canonical + FROM signer_reward_claims + WHERE signer_manager = ${SIGNER} AND reward_cycle = ${REWARD_CYCLE} + `; + return rows[0]; + } + + beforeEach(async () => { + await migrate('up'); + db = await PgWriteStore.connect({ usageName: 'tests', withNotifier: false, skipMigrations: true }); + api = await startApiServer({ datastore: db, chainId: STACKS_TESTNET.chainId }); + await db.update( + new TestBlockBuilder({ block_height: 1, block_hash: '0x01', index_block_hash: '0x01' }).build() + ); + }); + + afterEach(async () => { + await api.terminate(); + await db?.close(); + await migrate('down'); + }); + + test('a claim-rewards event is recorded as a signer claim aggregate', async () => { + await db.update( + new TestBlockBuilder({ + block_height: 2, + block_hash: '0x02', + index_block_hash: '0x02', + parent_block_hash: '0x01', + parent_index_block_hash: '0x01', + }) + .addTx({ tx_id: CLAIM_TX_ID }) + .addTxPox5Event({ name: Pox5EventName.ClaimRewards, data: claimData }) + .build() + ); + + const row = await claimRow(); + assert.ok(row, 'signer claim recorded'); + assert.equal(row.signer_manager, SIGNER); + assert.equal(BigInt(row.stx_earned), 2000n); + assert.equal(BigInt(row.stx_rewards_per_token), 100n); + assert.equal(BigInt(row.bond_totals), 13000n); + assert.equal(BigInt(row.total_rewards), 15000n); + // The per-bond breakdown round-trips through the jsonb column (stored as + // JSON text, like `bond_registrations.btc_lockup_txs`). + assert.deepEqual(JSON.parse(row.bond_rewards), claimData.bond_rewards); + assert.equal(row.canonical, true); + }); + + test('orphaning the claim block flips the signer claim non-canonical', async () => { + await db.update( + new TestBlockBuilder({ + block_height: 2, + block_hash: '0x02', + index_block_hash: '0x02', + parent_block_hash: '0x01', + parent_index_block_hash: '0x01', + }) + .addTx({ tx_id: CLAIM_TX_ID }) + .addTxPox5Event({ name: Pox5EventName.ClaimRewards, data: claimData }) + .build() + ); + assert.equal((await claimRow()).canonical, true); + + // Fork B branches from genesis and overtakes, orphaning the claim block. + 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 claimRow()).canonical, false, 'signer claim flipped non-canonical'); + }); +}); + +/** + * Principal bond positions are cursor-paginated by bond_index, so a principal + * enrolled in many bonds can be paged through. + */ +describe('pox-5 principal bond positions pagination', () => { + let db: PgWriteStore; + let api: ApiServer; + + interface BondPositionsCursorPage extends BondPositionsPage { + limit: number; + cursor: { next: string | null; previous: string | null; current: string | null }; + } + + 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; + } + function registerEvent(bondIndex: number) { + return { + bond_index: String(bondIndex), + signer: SIGNER, + staker: ALICE, + amount_ustx: AMOUNT_USTX.toString(), + sats_total: SBTC_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, + btc_lockup: { type: 'l2', txs: [] }, + }; + } + + beforeEach(async () => { + await migrate('up'); + db = await PgWriteStore.connect({ usageName: 'tests', withNotifier: false, skipMigrations: true }); + api = await startApiServer({ datastore: db, chainId: STACKS_TESTNET.chainId }); + // One block: set up bonds 0 and 1, allowlist + register alice in both. + 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.SetupBond, + data: { ...SETUP_BOND_DATA, bond_index: '1' }, + }) + .addTxPox5Event({ + name: Pox5EventName.AddToAllowlist, + data: { bond_index: '0', staker: ALICE, max_sats: ALICE_MAX_SATS.toString() }, + }) + .addTxPox5Event({ + name: Pox5EventName.AddToAllowlist, + data: { bond_index: '1', staker: ALICE, max_sats: ALICE_MAX_SATS.toString() }, + }) + .addTx({ tx_id: REGISTER_TX_ID }) + .addTxPox5Event({ name: Pox5EventName.RegisterForBond, data: registerEvent(0) }) + .addTxPox5Event({ name: Pox5EventName.RegisterForBond, data: registerEvent(1) }) + .build() + ); + }); + + afterEach(async () => { + await api.terminate(); + await db?.close(); + await migrate('down'); + }); + + test('pages bond positions by bond_index', async () => { + const page1 = await getJson( + `/extended/v3/principals/${ALICE}/staking/bonds?limit=1` + ); + assert.equal(page1.total, 2); + assert.equal(page1.limit, 1); + assert.equal(page1.results.length, 1); + assert.equal(page1.results[0].bond_index, 0); + assert.deepEqual(page1.cursor, { next: '1', previous: null, current: '0' }); + + const page2 = await getJson( + `/extended/v3/principals/${ALICE}/staking/bonds?limit=1&cursor=${page1.cursor.next}` + ); + assert.equal(page2.results.length, 1); + assert.equal(page2.results[0].bond_index, 1); + assert.deepEqual(page2.cursor, { next: null, previous: '0', current: '1' }); + }); + + test('summary materializes the aggregate of both bond positions', async () => { + const summary = await getJson(`/extended/v3/principals/${ALICE}/staking`); + assert.equal(summary.bonds.count, 2); + assert.equal(BigInt(summary.bonds.locked.btc), SBTC_SATS * 2n); + assert.equal(BigInt(summary.bonds.locked.stx), AMOUNT_USTX * 2n); + assert.equal(BigInt(summary.bonds.rewards.btc.accrued), 0n); + }); });