From 37d4e62b19b635edaf8c26232aacdf6ad3a570a2 Mon Sep 17 00:00:00 2001 From: Rafa Cardenas <253999660+rafa-stacks@users.noreply.github.com> Date: Fri, 12 Jun 2026 13:23:38 -0600 Subject: [PATCH 1/7] bond reward claims --- .../1779742831642_principal-bond-positions.ts | 9 + ...9745340755_principal-bond-reward-claims.ts | 102 +++++ openapi.yaml | 403 +++++++++++++++++- .../v3/entities/principal-bond-positions.ts | 31 +- src/api/serializers/v3/bonds.ts | 11 +- src/datastore/common.ts | 14 + src/datastore/pg-write-store.ts | 68 ++- src/datastore/v3/constants.ts | 1 + src/datastore/v3/types.ts | 1 + tests/api/pox5/bonds.test.ts | 145 ++++++- 10 files changed, 760 insertions(+), 25 deletions(-) create mode 100644 migrations/1779745340755_principal-bond-reward-claims.ts 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/openapi.yaml b/openapi.yaml index 20bcd90c6..92c5d2bbb 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -94032,7 +94032,6 @@ paths: - balances - enrollment - amount - - accrued_rewards properties: bond_index: description: The index of the bond in the PoX-5 bond list @@ -94058,7 +94057,7 @@ paths: type: object required: - locked - - paid_out + - rewards properties: locked: type: object @@ -94067,19 +94066,32 @@ paths: - stx properties: btc: - description: The total amount of BTC that is locked up for this bond + description: The amount of BTC that is locked up for this position type: string stx: - description: The total amount of STX that is locked up for this bond + description: The amount of STX that is locked up for this position type: string - paid_out: + rewards: type: object required: - btc properties: btc: - description: The total amount of BTC that has been paid out for this bond - type: string + type: object + required: + - accrued + - claimed + - claimable + properties: + accrued: + description: The lifetime sBTC reward sats accrued to this position + type: string + claimed: + description: The lifetime sBTC reward sats already claimed against this position + type: string + claimable: + description: The sBTC reward sats currently claimable (accrued minus claimed) + type: string enrollment: type: object required: @@ -94103,9 +94115,380 @@ paths: amount: description: The amount of STX that is locked up for this principal type: string - accrued_rewards: - description: The sBTC reward sats accrued to this participant's position - type: string + 4XX: + description: Default Response + content: + application/json: + schema: + title: Error Response + additionalProperties: true + type: object + required: + - error + properties: + error: + type: string + message: + type: string + /extended/v3/principals/{principal}/balances/stx: + get: + operationId: get_principal_stx_balance + summary: Get principal STX balance + tags: + - Accounts + description: "Get a principal's STX balance: its total and spendable (available) + balance, any locked STX, and the projected balance from pending mempool + transactions." + parameters: + - schema: + anyOf: + - pattern: ^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{28,41} + title: Stacks Address + description: Stacks Address + examples: + - SP318Q55DEKHRXJK696033DQN5C54D9K2EE6DHRWP + type: string + - pattern: ^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{28,41}.[a-zA-Z]([a-zA-Z0-9]|[-_]){0,39}$ + title: Smart Contract ID + description: Smart Contract ID + examples: + - SP000000000000000000002Q6VF78.pox-3 + type: string + in: path + name: principal + required: true + responses: + "200": + description: Default Response + content: + application/json: + schema: + title: PrincipalStxBalance + type: object + required: + - balance + - available + - locked + - mempool + properties: + balance: + description: Total micro-STX balance (available plus locked) + type: string + available: + description: Spendable micro-STX balance (balance minus locked) + type: string + locked: + anyOf: + - title: StxLock + type: object + required: + - amount + - pox_version + - lock_tx_id + - stacks_lock_height + - burn_lock_height + - burn_unlock_height + properties: + amount: + description: The amount of locked micro-STX + type: string + pox_version: + description: The PoX contract version that created the lock (e.g. 4, 5) + type: integer + lock_tx_id: + pattern: ^(0x)?[a-fA-F0-9]{64}$ + title: Transaction ID + description: Transaction ID + examples: + - "0xf6bd5f4a7b26184a3466340b2e99fd003b4962c0e382\ + a7e4b6a13df3dd7a91c6" + type: string + stacks_lock_height: + description: The Stacks block height at which the lock was created + type: integer + burn_lock_height: + description: The burnchain block height at which the lock was created + type: integer + burn_unlock_height: + description: The burnchain block height at which the locked STX unlocks + type: integer + - type: "null" + mempool: + anyOf: + - title: StxMempoolBalance + type: object + required: + - estimated_balance + - inbound + - outbound + properties: + estimated_balance: + description: Estimated spendable micro-STX balance once pending mempool txs + confirm + type: string + inbound: + description: Pending inbound micro-STX from the mempool + type: string + outbound: + description: Pending outbound micro-STX from the mempool (transfers plus fees) + type: string + - type: "null" + 4XX: + description: Default Response + content: + application/json: + schema: + title: Error Response + additionalProperties: true + type: object + required: + - error + properties: + error: + type: string + message: + type: string + /extended/v3/principals/{principal}/balances/ft: + get: + operationId: get_principal_ft_balances + summary: Get principal FT balances + tags: + - Accounts + description: Get a principal's fungible-token balances, sorted by balance descending. + parameters: + - schema: + minimum: 1 + default: 100 + maximum: 200 + type: integer + in: query + name: limit + required: false + description: Number of results per page + - schema: + pattern: ^\d+:.+$ + type: string + in: query + name: cursor + required: false + description: "Cursor for paginating FT balances (sorted by balance, descending). + Format: balance:asset_identifier" + - schema: + anyOf: + - pattern: ^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{28,41} + title: Stacks Address + description: Stacks Address + examples: + - SP318Q55DEKHRXJK696033DQN5C54D9K2EE6DHRWP + type: string + - pattern: ^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{28,41}.[a-zA-Z]([a-zA-Z0-9]|[-_]){0,39}$ + title: Smart Contract ID + description: Smart Contract ID + examples: + - SP000000000000000000002Q6VF78.pox-3 + type: string + in: path + name: principal + required: true + responses: + "200": + description: Default Response + content: + application/json: + schema: + type: object + required: + - total + - limit + - cursor + - results + properties: + total: + type: integer + example: 1 + limit: + minimum: 1 + default: 100 + maximum: 200 + description: Number of results per page + type: integer + cursor: + type: object + required: + - next + - previous + - current + properties: + next: + anyOf: + - pattern: ^\d+:.+$ + description: "Cursor for paginating FT balances (sorted by balance, descending). + Format: balance:asset_identifier" + type: string + - type: "null" + previous: + anyOf: + - pattern: ^\d+:.+$ + description: "Cursor for paginating FT balances (sorted by balance, descending). + Format: balance:asset_identifier" + type: string + - type: "null" + current: + anyOf: + - pattern: ^\d+:.+$ + description: "Cursor for paginating FT balances (sorted by balance, descending). + Format: balance:asset_identifier" + type: string + - type: "null" + results: + type: array + items: + title: PrincipalFtPosition + type: object + required: + - asset_identifier + - balance + properties: + asset_identifier: + description: Fungible token asset identifier + type: string + example: SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token::sbtc-token + balance: + description: The principal's balance of this token, as a string-quoted integer + in base units + type: string + 4XX: + description: Default Response + content: + application/json: + schema: + title: Error Response + additionalProperties: true + type: object + required: + - error + properties: + error: + type: string + message: + type: string + /extended/v3/principals/{principal}/balances/nft: + get: + operationId: get_principal_nft_balances + summary: Get principal NFT balances + tags: + - Accounts + description: Get the non-fungible token instances currently owned by a + principal, ordered by asset identifier and value. + parameters: + - schema: + minimum: 1 + default: 100 + maximum: 200 + type: integer + in: query + name: limit + required: false + description: Number of results per page + - schema: + pattern: ^0x[0-9a-fA-F]*:.+$ + type: string + in: query + name: cursor + required: false + description: "Cursor for paginating NFT balances (sorted by asset identifier + then value). Format: value:asset_identifier" + - schema: + anyOf: + - pattern: ^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{28,41} + title: Stacks Address + description: Stacks Address + examples: + - SP318Q55DEKHRXJK696033DQN5C54D9K2EE6DHRWP + type: string + - pattern: ^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{28,41}.[a-zA-Z]([a-zA-Z0-9]|[-_]){0,39}$ + title: Smart Contract ID + description: Smart Contract ID + examples: + - SP000000000000000000002Q6VF78.pox-3 + type: string + in: path + name: principal + required: true + responses: + "200": + description: Default Response + content: + application/json: + schema: + type: object + required: + - total + - limit + - cursor + - results + properties: + total: + type: integer + example: 1 + limit: + minimum: 1 + default: 100 + maximum: 200 + description: Number of results per page + type: integer + cursor: + type: object + required: + - next + - previous + - current + properties: + next: + anyOf: + - pattern: ^0x[0-9a-fA-F]*:.+$ + description: "Cursor for paginating NFT balances (sorted by asset identifier + then value). Format: value:asset_identifier" + type: string + - type: "null" + previous: + anyOf: + - pattern: ^0x[0-9a-fA-F]*:.+$ + description: "Cursor for paginating NFT balances (sorted by asset identifier + then value). Format: value:asset_identifier" + type: string + - type: "null" + current: + anyOf: + - pattern: ^0x[0-9a-fA-F]*:.+$ + description: "Cursor for paginating NFT balances (sorted by asset identifier + then value). Format: value:asset_identifier" + type: string + - type: "null" + results: + type: array + items: + title: PrincipalNftPosition + type: object + required: + - asset_identifier + - value + properties: + asset_identifier: + description: Non-fungible token asset identifier + type: string + example: SP2X0TZ59D5SZ8ACQ6YMCHHNR2ZN51Z32E2CJ173.the-explorer-guild::The-Explorer-Guild + value: + description: The NFT instance identifier, as a Clarity value + type: object + required: + - hex + - repr + properties: + hex: + type: string + repr: + type: string 4XX: description: Default Response content: diff --git a/src/api/schemas/v3/entities/principal-bond-positions.ts b/src/api/schemas/v3/entities/principal-bond-positions.ts index b0106bff8..54ba4c6e3 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,38 @@ export const PrincipalBondPositionStatusSchema = Type.Union([ ]); export type PrincipalBondPositionStatus = Static; +export const PrincipalBondPositionBalancesSchema = Type.Object({ + locked: Type.Object({ + btc: Type.String({ + description: 'The amount of BTC that is locked up for this position', + }), + stx: Type.String({ + description: 'The amount of STX that is locked up for this position', + }), + }), + rewards: Type.Object({ + btc: 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 PrincipalBondPositionBalances = Static; + export const PrincipalBondPositionSchema = Type.Object({ bond_index: BondIndexSchema, status: PrincipalBondPositionStatusSchema, active: Type.Boolean({ description: 'Whether the position is active', }), - balances: BondBalancesSchema, + balances: PrincipalBondPositionBalancesSchema, enrollment: Type.Object({ tx_id: TransactionIdSchema, btc_lockup: Type.Object({ @@ -28,8 +52,5 @@ 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 1d17fc4f9..e22e1696a 100644 --- a/src/api/serializers/v3/bonds.ts +++ b/src/api/serializers/v3/bonds.ts @@ -140,8 +140,14 @@ export function serializeDbPrincipalBondPosition( btc: position.btc_locked, stx: position.stx_locked, }, - paid_out: { - btc: position.btc_paid_out, + rewards: { + btc: { + accrued: position.accrued_rewards, + claimed: position.claimed_rewards, + claimable: ( + BigInt(position.accrued_rewards) - BigInt(position.claimed_rewards) + ).toString(), + }, }, }, enrollment: { @@ -151,7 +157,6 @@ 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 4a1bb17c3..a8b769b2d 100644 --- a/src/datastore/common.ts +++ b/src/datastore/common.ts @@ -1727,6 +1727,20 @@ 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-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..363f92afe 100644 --- a/src/datastore/pg-write-store.ts +++ b/src/datastore/pg-write-store.ts @@ -75,6 +75,7 @@ import { DbBondRewardCalculationInsertValues, DbBondRewardDistributionInsertValues, DbPrincipalBondRewardDistributionInsertValues, + DbPrincipalBondRewardClaimInsertValues, } from './common.js'; import { BLOCK_COLUMNS, @@ -115,6 +116,7 @@ import { Pox5EventAnnounceL1EarlyExit, Pox5EventBondDistribution, Pox5EventCalculateRewards, + Pox5EventClaimStakerRewardsForSigner, Pox5EventName, Pox5EventRegisterForBond, Pox5EventSetupBond, @@ -583,9 +585,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: + // TODO: Implement (per-signer claim aggregate) break; case Pox5EventName.Stake: case Pox5EventName.StakeUpdate: @@ -905,6 +909,42 @@ export class PgWriteStore extends PgStore { } } + /** + * 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) { + 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 + `; + } + /** Cycle-level reward aggregate, from the pox-5 `calculate-rewards` event. */ private async updateBondRewardCalculation( sql: PgSqlClient, @@ -4439,6 +4479,30 @@ export class PgWriteStore extends PgStore { WHERE p.principal = c.principal AND p.bond_index = c.bond_index `; }); + q.enqueue(async () => { + // Flip the per-staker reward claim source rows and apply the signed delta + // to each position's running claimed_rewards total. STX-staking claims + // (NULL bond_index) have no position to update — the flip alone suffices. + 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 + ) + 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 + `; + }); 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 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/types.ts b/src/datastore/v3/types.ts index 66f68ec6d..b05f5e237 100644 --- a/src/datastore/v3/types.ts +++ b/src/datastore/v3/types.ts @@ -193,6 +193,7 @@ export interface DbPrincipalBondPosition { stx_locked: string; btc_paid_out: string; accrued_rewards: string; + claimed_rewards: string; tx_id: string; } diff --git a/tests/api/pox5/bonds.test.ts b/tests/api/pox5/bonds.test.ts index 475866b35..79d9eb2d7 100644 --- a/tests/api/pox5/bonds.test.ts +++ b/tests/api/pox5/bonds.test.ts @@ -95,10 +95,12 @@ interface PrincipalBondPositionItem { bond_index: number; status: string; active: boolean; - balances: { locked: { btc: string; stx: string }; paid_out: { btc: string } }; + balances: { + locked: { btc: string; stx: string }; + rewards: { btc: { accrued: string; claimed: string; claimable: string } }; + }; enrollment: { tx_id: string; btc_lockup: { amount: string } }; amount: string; - accrued_rewards: string; } const normalizeTxId = (txid: string) => txid.replace(/^0x/, '').toLowerCase(); @@ -313,7 +315,7 @@ describe('pox-5 bonds (simulated ingestion)', () => { // 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.balances.rewards.btc.accrued), 0n); assert.equal(BigInt(pos.amount), AMOUNT_USTX); assert.equal(BigInt(pos.enrollment.btc_lockup.amount), SBTC_SATS); // The position links back to the register-for-bond transaction. @@ -903,13 +905,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 { + async function rewardsFor( + principal: string + ): Promise<{ accrued: bigint; claimed: bigint; claimable: bigint }> { 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); + return { + accrued: BigInt(pos.balances.rewards.btc.accrued), + claimed: BigInt(pos.balances.rewards.btc.claimed), + claimable: BigInt(pos.balances.rewards.btc.claimable), + }; + } + async function accruedFor(principal: string): Promise { + return (await rewardsFor(principal)).accrued; } function registerEvent(staker: string, sats: bigint) { return { @@ -1037,4 +1048,128 @@ 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); + }); }); From a06f105c15844c727ad66f231241e84c557c4f3a Mon Sep 17 00:00:00 2001 From: Rafa Cardenas <253999660+rafa-stacks@users.noreply.github.com> Date: Tue, 16 Jun 2026 09:28:10 -0600 Subject: [PATCH 2/7] feat: STX-staking reward accrual and claims --- ...800000003_principal-stx-staking-rewards.ts | 37 +++ ...0004_principal-stx-reward-distributions.ts | 93 +++++++ src/api/routes/v3/principals.ts | 12 +- .../v3/entities/principal-bond-positions.ts | 48 +++- src/api/serializers/v3/bonds.ts | 34 ++- src/datastore/common.ts | 13 + src/datastore/pg-write-store.ts | 89 ++++++- src/datastore/v3/pg-store-v3.ts | 27 +- src/datastore/v3/types.ts | 14 + tests/api/pox5/bonds.test.ts | 244 ++++++++++++++++-- 10 files changed, 567 insertions(+), 44 deletions(-) create mode 100644 migrations/1779800000003_principal-stx-staking-rewards.ts create mode 100644 migrations/1779800000004_principal-stx-reward-distributions.ts diff --git a/migrations/1779800000003_principal-stx-staking-rewards.ts b/migrations/1779800000003_principal-stx-staking-rewards.ts new file mode 100644 index 000000000..bb069ea67 --- /dev/null +++ b/migrations/1779800000003_principal-stx-staking-rewards.ts @@ -0,0 +1,37 @@ +import type { ColumnDefinitions, MigrationBuilder } from 'node-pg-migrate'; + +export const shorthands: ColumnDefinitions | undefined = undefined; + +/** + * Running per-principal sBTC reward totals for pox-5 STX staking (the rewards a + * principal earns from locking STX, as opposed to participating in a bond). + * This is a pure materialized accumulator like `ft_balances` — one row per + * principal, no canonical/tx-location columns — fed incrementally on write and + * delta-corrected on reorg from the source rows in + * `principal_stx_reward_distributions` (accruals) and the NULL-`bond_index` + * rows of `principal_bond_reward_claims` (claims). Claimable rewards are + * `accrued_rewards - claimed_rewards`. + */ +export function up(pgm: MigrationBuilder): void { + pgm.createTable('principal_stx_staking_rewards', { + principal: { + type: 'text', + notNull: true, + primaryKey: true, + }, + accrued_rewards: { + type: 'numeric', + notNull: true, + default: 0, + }, + claimed_rewards: { + type: 'numeric', + notNull: true, + default: 0, + }, + }); +} + +export function down(pgm: MigrationBuilder): void { + pgm.dropTable('principal_stx_staking_rewards'); +} diff --git a/migrations/1779800000004_principal-stx-reward-distributions.ts b/migrations/1779800000004_principal-stx-reward-distributions.ts new file mode 100644 index 000000000..adae01fa7 --- /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_stx_staking_rewards.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/src/api/routes/v3/principals.ts b/src/api/routes/v3/principals.ts index a31a6e4fc..b11064227 100644 --- a/src/api/routes/v3/principals.ts +++ b/src/api/routes/v3/principals.ts @@ -17,8 +17,8 @@ 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 { PrincipalStakingBalancesSchema } from '../../schemas/v3/entities/principal-bond-positions.js'; +import { serializeDbPrincipalStakingBalances } from '../../serializers/v3/bonds.js'; import { handleChainTipCache } from '../../controllers/cache-controller.js'; import { PrincipalFtPositionSchema, @@ -79,19 +79,19 @@ export const PrincipalsRoutes: FastifyPluginAsync< operationId: 'get_principal_staking_balances', summary: '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", + "Get a principal's staking balances: its bond positions (staked amounts and sBTC rewards) across all bonds it is enrolled in, plus its pox-5 STX-staking position (locked STX and its sBTC rewards)", tags: ['Staking'], params: Type.Object({ principal: PrincipalSchema }), response: { - 200: Type.Array(PrincipalBondPositionSchema), + 200: PrincipalStakingBalancesSchema, }, }, }, async (req, reply) => { - const positions = await fastify.db.v3.getPrincipalStakingPositions({ + const balances = await fastify.db.v3.getPrincipalStakingBalances({ principal: req.params.principal, }); - await reply.send(positions.map(serializeDbPrincipalBondPosition)); + await reply.send(serializeDbPrincipalStakingBalances(balances)); } ); diff --git a/src/api/schemas/v3/entities/principal-bond-positions.ts b/src/api/schemas/v3/entities/principal-bond-positions.ts index 54ba4c6e3..aa8b80e47 100644 --- a/src/api/schemas/v3/entities/principal-bond-positions.ts +++ b/src/api/schemas/v3/entities/principal-bond-positions.ts @@ -9,6 +9,20 @@ 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; + export const PrincipalBondPositionBalancesSchema = Type.Object({ locked: Type.Object({ btc: Type.String({ @@ -19,17 +33,7 @@ export const PrincipalBondPositionBalancesSchema = Type.Object({ }), }), rewards: Type.Object({ - btc: 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)', - }), - }), + btc: BtcRewardsSchema, }), }); export type PrincipalBondPositionBalances = Static; @@ -54,3 +58,25 @@ export const PrincipalBondPositionSchema = Type.Object({ }), }); 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 principal's full staking picture: its bond positions plus its STX-staking position. */ +export const PrincipalStakingBalancesSchema = Type.Object({ + bonds: Type.Array(PrincipalBondPositionSchema), + stx: PrincipalStxStakingPositionSchema, +}); +export type PrincipalStakingBalances = Static; diff --git a/src/api/serializers/v3/bonds.ts b/src/api/serializers/v3/bonds.ts index e22e1696a..94ade23c3 100644 --- a/src/api/serializers/v3/bonds.ts +++ b/src/api/serializers/v3/bonds.ts @@ -5,6 +5,7 @@ import { DbBondRegistrationSummary, DbBondSummary, DbPrincipalBondPosition, + DbPrincipalStakingBalances, } from '../../../datastore/v3/types.js'; import { DbBondLockupType, DbPrincipalBondPositionStatus } from '../../../datastore/common.js'; import { Bond, BondSummary } from '../../schemas/v3/entities/bonds.js'; @@ -13,10 +14,21 @@ 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, + PrincipalStakingBalances, } from '../../schemas/v3/entities/principal-bond-positions.js'; +/** Build the `{ accrued, claimed, claimable }` sBTC reward triple from running totals. */ +function btcRewards(accrued: string, claimed: string): BtcRewards { + return { + accrued, + claimed, + claimable: (BigInt(accrued) - BigInt(claimed)).toString(), + }; +} + function getBondStatus(summary: DbBondSummary, currentBurnBlockHeight: number): BondStatus { if (currentBurnBlockHeight < summary.bond_start_height) { return 'upcoming'; @@ -141,13 +153,7 @@ export function serializeDbPrincipalBondPosition( stx: position.stx_locked, }, rewards: { - btc: { - accrued: position.accrued_rewards, - claimed: position.claimed_rewards, - claimable: ( - BigInt(position.accrued_rewards) - BigInt(position.claimed_rewards) - ).toString(), - }, + btc: btcRewards(position.accrued_rewards, position.claimed_rewards), }, }, enrollment: { @@ -160,6 +166,20 @@ export function serializeDbPrincipalBondPosition( }; } +export function serializeDbPrincipalStakingBalances( + balances: DbPrincipalStakingBalances +): PrincipalStakingBalances { + return { + bonds: balances.bonds.map(serializeDbPrincipalBondPosition), + stx: { + locked: balances.stx.locked, + rewards: { + btc: btcRewards(balances.stx.accrued_rewards, balances.stx.claimed_rewards), + }, + }, + }; +} + export function serializeDbBondRegistrationSummary( entry: DbBondRegistrationSummary ): BondRegistrationSummary { diff --git a/src/datastore/common.ts b/src/datastore/common.ts index 830d35fc7..821e128e7 100644 --- a/src/datastore/common.ts +++ b/src/datastore/common.ts @@ -1742,6 +1742,19 @@ export interface DbPrincipalBondRewardClaimInsertValues extends DbTxLocation { 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_stx_staking_rewards.accrued_rewards` + * total under reorgs. + */ +export interface DbPrincipalStxRewardDistributionInsertValues extends DbTxLocation { + principal: string; + reward_cycle: number; + reward_amount: 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 363f92afe..b6d4d994b 100644 --- a/src/datastore/pg-write-store.ts +++ b/src/datastore/pg-write-store.ts @@ -76,6 +76,7 @@ import { DbBondRewardDistributionInsertValues, DbPrincipalBondRewardDistributionInsertValues, DbPrincipalBondRewardClaimInsertValues, + DbPrincipalStxRewardDistributionInsertValues, } from './common.js'; import { BLOCK_COLUMNS, @@ -933,6 +934,14 @@ export class PgWriteStore extends PgStore { 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_stx_staking_rewards (principal, claimed_rewards) + VALUES (${event.data.staker}, ${event.data.rewards_claimed}::numeric) + ON CONFLICT (principal) DO UPDATE + SET claimed_rewards = principal_stx_staking_rewards.claimed_rewards + EXCLUDED.claimed_rewards + `; return; } await sql` @@ -967,6 +976,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_stx_staking_rewards (principal, accrued_rewards) + VALUES (${s.principal}, ${s.reward_amount}::numeric) + ON CONFLICT (principal) DO UPDATE + SET accrued_rewards = principal_stx_staking_rewards.accrued_rewards + EXCLUDED.accrued_rewards + `; + } } /** @@ -4481,8 +4526,7 @@ export class PgWriteStore extends PgStore { }); q.enqueue(async () => { // Flip the per-staker reward claim source rows and apply the signed delta - // to each position's running claimed_rewards total. STX-staking claims - // (NULL bond_index) have no position to update — the flip alone suffices. + // to each bond position's running claimed_rewards total (bond claims). await sql` WITH updated AS ( UPDATE principal_bond_reward_claims @@ -4502,6 +4546,47 @@ export class PgWriteStore extends PgStore { FROM changes c WHERE p.principal = c.principal AND p.bond_index = c.bond_index `; + // The flip above also settled the STX-staking claims (NULL bond_index); + // apply their signed delta to the STX-staking claimed_rewards 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_stx_staking_rewards p + SET claimed_rewards = ${ + canonical ? sql`p.claimed_rewards + c.total` : sql`p.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_stx_staking_rewards p + SET accrued_rewards = p.accrued_rewards + c.reward_change + FROM changes c + WHERE p.principal = c.principal + `; }); q.enqueue(async () => { const contractLogResult = await sql<{ contract_identifier: string; delta: number }[]>` diff --git a/src/datastore/v3/pg-store-v3.ts b/src/datastore/v3/pg-store-v3.ts index 4428df757..010a1745f 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, + DbPrincipalStakingBalances, DbPrincipalTransactionSummary, DbTransaction, DbTransactionCursor, @@ -1163,11 +1164,11 @@ export class PgStoreV3 extends BasePgStoreModule { * @param args - The arguments for the query. * @returns The principal's bond positions. */ - async getPrincipalStakingPositions(args: { + async getPrincipalStakingBalances(args: { principal: Principal; - }): Promise { + }): Promise { return await this.sqlTransaction(async sql => { - return await sql` + const bonds = await sql` SELECT ${sql(PRINCIPAL_BOND_POSITION_COLUMNS)} FROM principal_bond_positions WHERE canonical = true @@ -1175,6 +1176,26 @@ export class PgStoreV3 extends BasePgStoreModule { AND principal = ${args.principal} ORDER BY bond_index ASC `; + // The pox-5 STX staking lock (latest-wins materialized row) and the + // running STX-staking reward totals. + const [locked] = await sql<{ locked_amount: string }[]>` + SELECT locked_amount FROM stx_locked_balances + WHERE principal = ${args.principal} AND pox_version = 5 + LIMIT 1 + `; + const [rewards] = await sql<{ accrued_rewards: string; claimed_rewards: string }[]>` + SELECT accrued_rewards, claimed_rewards FROM principal_stx_staking_rewards + WHERE principal = ${args.principal} + LIMIT 1 + `; + return { + bonds, + stx: { + locked: locked?.locked_amount ?? '0', + accrued_rewards: rewards?.accrued_rewards ?? '0', + claimed_rewards: rewards?.claimed_rewards ?? '0', + }, + }; }); } } diff --git a/src/datastore/v3/types.ts b/src/datastore/v3/types.ts index b05f5e237..a16872755 100644 --- a/src/datastore/v3/types.ts +++ b/src/datastore/v3/types.ts @@ -197,6 +197,20 @@ export interface DbPrincipalBondPosition { 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; +} + +/** A principal's full staking picture: its bond positions plus its STX-staking position. */ +export interface DbPrincipalStakingBalances { + bonds: DbPrincipalBondPosition[]; + stx: DbPrincipalStxStaking; +} + 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 79d9eb2d7..b8732307b 100644 --- a/tests/api/pox5/bonds.test.ts +++ b/tests/api/pox5/bonds.test.ts @@ -91,17 +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 }; - rewards: { btc: { accrued: string; claimed: string; claimable: string } }; + rewards: { btc: BtcRewardsItem }; }; enrollment: { tx_id: string; btc_lockup: { amount: string } }; amount: string; } +interface StakingBalances { + bonds: PrincipalBondPositionItem[]; + stx: { locked: string; rewards: { btc: BtcRewardsItem } }; +} const normalizeTxId = (txid: string) => txid.replace(/^0x/, '').toLowerCase(); @@ -305,10 +314,10 @@ describe('pox-5 bonds (simulated ingestion)', () => { }); test("alice's position appears in GET .../principals/:principal/balances/staking", async () => { - const positions = await getJson( + const staking = await getJson( `/extended/v3/principals/${ALICE}/balances/staking` ); - const pos = positions.find(p => p.bond_index === BOND_INDEX); + const pos = staking.bonds.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); @@ -556,7 +565,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/principals/${ALICE}/balances/staking`); + const positions = (await getJson(`/extended/v3/principals/${ALICE}/balances/staking`)).bonds; assert.equal(positions.length, 1, 'position visible on fork A'); // Fork B overtakes fork A (height 2 then 3) — no bond on this fork. @@ -587,7 +596,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/principals/${ALICE}/balances/staking`); + const positionsAfter = (await getJson(`/extended/v3/principals/${ALICE}/balances/staking`)).bonds; assert.equal(positionsAfter.length, 0, 'position gone after reorg'); // Fork A wins again (extends to height 4) — the bond is restored. @@ -616,9 +625,9 @@ 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 positionsRestored = ( + await getJson(`/extended/v3/principals/${ALICE}/balances/staking`) + ).bonds; assert.equal(positionsRestored.length, 1, 'position restored'); }); @@ -726,9 +735,9 @@ 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` - ); + const positions = ( + await getJson(`/extended/v3/principals/${ALICE}/balances/staking`) + ).bonds; assert.equal(positions.length, 0, 'position orphaned'); }); }); @@ -756,8 +765,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}/balances/staking`)).bonds; const getBond = () => getJson(`/extended/v3/staking/bonds/${BOND_INDEX}`); beforeEach(async () => { @@ -908,10 +917,10 @@ describe('pox-5 bonds reward accrual', () => { async function rewardsFor( principal: string ): Promise<{ accrued: bigint; claimed: bigint; claimable: bigint }> { - const positions = await getJson( + const staking = await getJson( `/extended/v3/principals/${principal}/balances/staking` ); - const pos = positions.find(p => p.bond_index === BOND_INDEX); + const pos = staking.bonds.find(p => p.bond_index === BOND_INDEX); assert.ok(pos, `position for ${principal}`); return { accrued: BigInt(pos.balances.rewards.btc.accrued), @@ -1173,3 +1182,208 @@ describe('pox-5 bonds reward accrual', () => { assert.equal(alice.claimable, ALICE_EXPECTED); }); }); + +/** + * 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 staking = await getJson( + `/extended/v3/principals/${principal}/balances/staking` + ); + return { + locked: BigInt(staking.stx.locked), + accrued: BigInt(staking.stx.rewards.btc.accrued), + claimed: BigInt(staking.stx.rewards.btc.claimed), + claimable: BigInt(staking.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), + unlock_burn_height: '500', + 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 staking = await getJson( + `/extended/v3/principals/${ALICE}/balances/staking` + ); + assert.equal(staking.bonds.length, 0, 'STX staking has no bond positions'); + }); + + 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); + }); +}); From a1158047d17cd2e59d1bb5dc7c27da6642eeaa7c Mon Sep 17 00:00:00 2001 From: Rafa Cardenas <253999660+rafa-stacks@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:08:26 -0600 Subject: [PATCH 3/7] claimrewards per signer --- .../1779800000005_signer-reward-claims.ts | 113 ++++++++++++++++ src/datastore/common.ts | 15 +++ src/datastore/pg-write-store.ts | 30 ++++- tests/api/pox5/bonds.test.ts | 126 ++++++++++++++++++ 4 files changed, 283 insertions(+), 1 deletion(-) create mode 100644 migrations/1779800000005_signer-reward-claims.ts diff --git a/migrations/1779800000005_signer-reward-claims.ts b/migrations/1779800000005_signer-reward-claims.ts new file mode 100644 index 000000000..59bd6abad --- /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_stx_staking_rewards` — 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/datastore/common.ts b/src/datastore/common.ts index 821e128e7..0001821e7 100644 --- a/src/datastore/common.ts +++ b/src/datastore/common.ts @@ -1755,6 +1755,21 @@ export interface DbPrincipalStxRewardDistributionInsertValues extends DbTxLocati 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 b6d4d994b..03725f8b6 100644 --- a/src/datastore/pg-write-store.ts +++ b/src/datastore/pg-write-store.ts @@ -77,6 +77,7 @@ import { DbPrincipalBondRewardDistributionInsertValues, DbPrincipalBondRewardClaimInsertValues, DbPrincipalStxRewardDistributionInsertValues, + DbSignerRewardClaimInsertValues, } from './common.js'; import { BLOCK_COLUMNS, @@ -117,6 +118,7 @@ import { Pox5EventAnnounceL1EarlyExit, Pox5EventBondDistribution, Pox5EventCalculateRewards, + Pox5EventClaimRewards, Pox5EventClaimStakerRewardsForSigner, Pox5EventName, Pox5EventRegisterForBond, @@ -590,7 +592,7 @@ export class PgWriteStore extends PgStore { await this.updatePrincipalBondRewardClaim(sql, txLocation, poxEvent); break; case Pox5EventName.ClaimRewards: - // TODO: Implement (per-signer claim aggregate) + await this.updateSignerRewardClaim(sql, txLocation, poxEvent); break; case Pox5EventName.Stake: case Pox5EventName.StakeUpdate: @@ -954,6 +956,31 @@ export class PgWriteStore extends PgStore { `; } + /** + * 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. */ private async updateBondRewardCalculation( sql: PgSqlClient, @@ -4421,6 +4448,7 @@ export class PgWriteStore extends PgStore { 'bonds', 'bond_reward_distributions', 'bond_reward_calculations', + 'signer_reward_claims', ]) { q.enqueue(async () => { await sql` diff --git a/tests/api/pox5/bonds.test.ts b/tests/api/pox5/bonds.test.ts index b8732307b..153dc5388 100644 --- a/tests/api/pox5/bonds.test.ts +++ b/tests/api/pox5/bonds.test.ts @@ -1387,3 +1387,129 @@ describe('pox-5 STX-staking reward accrual', () => { 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'); + }); +}); From 828bc69dd0743773113e95df6568e01b5c98b047 Mon Sep 17 00:00:00 2001 From: Rafa Cardenas <253999660+rafa-stacks@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:23:59 -0600 Subject: [PATCH 4/7] resolve stx locked --- src/datastore/v3/pg-store-v3.ts | 20 ++++++++++++++------ tests/api/pox5/bonds.test.ts | 29 ++++++++++++++++++++++++++++- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/src/datastore/v3/pg-store-v3.ts b/src/datastore/v3/pg-store-v3.ts index 010a1745f..836eed351 100644 --- a/src/datastore/v3/pg-store-v3.ts +++ b/src/datastore/v3/pg-store-v3.ts @@ -30,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'; @@ -1176,13 +1176,21 @@ export class PgStoreV3 extends BasePgStoreModule { AND principal = ${args.principal} ORDER BY bond_index ASC `; - // The pox-5 STX staking lock (latest-wins materialized row) and the - // running STX-staking reward totals. - const [locked] = await sql<{ locked_amount: string }[]>` - SELECT locked_amount FROM stx_locked_balances + // 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); const [rewards] = await sql<{ accrued_rewards: string; claimed_rewards: string }[]>` SELECT accrued_rewards, claimed_rewards FROM principal_stx_staking_rewards WHERE principal = ${args.principal} @@ -1191,7 +1199,7 @@ export class PgStoreV3 extends BasePgStoreModule { return { bonds, stx: { - locked: locked?.locked_amount ?? '0', + locked: resolvedLock.locked.toString(), accrued_rewards: rewards?.accrued_rewards ?? '0', claimed_rewards: rewards?.claimed_rewards ?? '0', }, diff --git a/tests/api/pox5/bonds.test.ts b/tests/api/pox5/bonds.test.ts index 153dc5388..ecb5948d6 100644 --- a/tests/api/pox5/bonds.test.ts +++ b/tests/api/pox5/bonds.test.ts @@ -1228,7 +1228,9 @@ describe('pox-5 STX-staking reward accrual', () => { amount_ustx: ustx.toString(), num_cycles: '2', first_reward_cycle: String(STX_CYCLE), - unlock_burn_height: '500', + // 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', }; } @@ -1310,6 +1312,31 @@ describe('pox-5 STX-staking reward accrual', () => { assert.equal(staking.bonds.length, 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({ From 7a7805e0430955cbd20a2f115b59036dc9978591 Mon Sep 17 00:00:00 2001 From: Rafa Cardenas <253999660+rafa-stacks@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:48:59 -0600 Subject: [PATCH 5/7] separate endpoints --- src/api/routes/v3/principals.ts | 65 ++++++- .../v3/entities/principal-bond-positions.ts | 46 ++--- .../principal-staking-balances-response.ts | 13 -- src/api/serializers/v3/bonds.ts | 42 +++-- src/datastore/v3/pg-store-v3.ts | 116 ++++++++++-- src/datastore/v3/types.ts | 15 +- tests/api/pox5/bonds.test.ts | 178 +++++++++++++----- 7 files changed, 345 insertions(+), 130 deletions(-) delete mode 100644 src/api/schemas/v3/responses/principal-staking-balances-response.ts diff --git a/src/api/routes/v3/principals.ts b/src/api/routes/v3/principals.ts index b11064227..4937c79ef 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 { PrincipalStakingBalancesSchema } from '../../schemas/v3/entities/principal-bond-positions.js'; -import { serializeDbPrincipalStakingBalances } 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_staking_balances', - 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 sBTC rewards) across all bonds it is enrolled in, plus its pox-5 STX-staking position (locked STX and its sBTC rewards)", + "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: PrincipalStakingBalancesSchema, + 200: PrincipalStakingSummarySchema, }, }, }, async (req, reply) => { - const balances = await fastify.db.v3.getPrincipalStakingBalances({ + const summary = await fastify.db.v3.getPrincipalStakingSummary({ principal: req.params.principal, }); - await reply.send(serializeDbPrincipalStakingBalances(balances)); + 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 aa8b80e47..a734e2339 100644 --- a/src/api/schemas/v3/entities/principal-bond-positions.ts +++ b/src/api/schemas/v3/entities/principal-bond-positions.ts @@ -23,28 +23,13 @@ export const BtcRewardsSchema = Type.Object({ }); export type BtcRewards = Static; -export const PrincipalBondPositionBalancesSchema = Type.Object({ - locked: Type.Object({ - btc: Type.String({ - description: 'The amount of BTC that is locked up for this position', - }), - stx: Type.String({ - description: 'The amount of STX that is locked up for this position', - }), - }), - rewards: Type.Object({ - btc: BtcRewardsSchema, - }), -}); -export type PrincipalBondPositionBalances = 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: PrincipalBondPositionBalancesSchema, enrollment: Type.Object({ tx_id: TransactionIdSchema, btc_lockup: Type.Object({ @@ -53,8 +38,12 @@ 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' }), + }), + rewards: Type.Object({ + btc: BtcRewardsSchema, }), }); export type PrincipalBondPosition = Static; @@ -74,9 +63,22 @@ export const PrincipalStxStakingPositionSchema = Type.Object({ }); export type PrincipalStxStakingPosition = Static; -/** A principal's full staking picture: its bond positions plus its STX-staking position. */ -export const PrincipalStakingBalancesSchema = Type.Object({ - bonds: Type.Array(PrincipalBondPositionSchema), +/** + * 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 PrincipalStakingBalances = Static; +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 94ade23c3..7dac1086f 100644 --- a/src/api/serializers/v3/bonds.ts +++ b/src/api/serializers/v3/bonds.ts @@ -5,7 +5,7 @@ import { DbBondRegistrationSummary, DbBondSummary, DbPrincipalBondPosition, - DbPrincipalStakingBalances, + DbPrincipalStakingSummary, } from '../../../datastore/v3/types.js'; import { DbBondLockupType, DbPrincipalBondPositionStatus } from '../../../datastore/common.js'; import { Bond, BondSummary } from '../../schemas/v3/entities/bonds.js'; @@ -17,7 +17,7 @@ import { BtcRewards, PrincipalBondPosition, PrincipalBondPositionStatus, - PrincipalStakingBalances, + PrincipalStakingSummary, } from '../../schemas/v3/entities/principal-bond-positions.js'; /** Build the `{ accrued, claimed, claimable }` sBTC reward triple from running totals. */ @@ -147,34 +147,40 @@ export function serializeDbPrincipalBondPosition( bond_index: position.bond_index, status: getPrincipalBondPositionStatus(position.status), active: position.active, - balances: { - locked: { - btc: position.btc_locked, - stx: position.stx_locked, - }, - rewards: { - btc: btcRewards(position.accrued_rewards, position.claimed_rewards), - }, - }, enrollment: { tx_id: position.tx_id, btc_lockup: { amount: position.btc_locked, }, }, - amount: position.stx_locked, + locked: { + btc: position.btc_locked, + stx: position.stx_locked, + }, + rewards: { + btc: btcRewards(position.accrued_rewards, position.claimed_rewards), + }, }; } -export function serializeDbPrincipalStakingBalances( - balances: DbPrincipalStakingBalances -): PrincipalStakingBalances { +export function serializeDbPrincipalStakingSummary( + summary: DbPrincipalStakingSummary +): PrincipalStakingSummary { return { - bonds: balances.bonds.map(serializeDbPrincipalBondPosition), stx: { - locked: balances.stx.locked, + locked: summary.stx.locked, + rewards: { + btc: btcRewards(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: btcRewards(balances.stx.accrued_rewards, balances.stx.claimed_rewards), + btc: btcRewards(summary.bonds.accrued_rewards, summary.bonds.claimed_rewards), }, }, }; diff --git a/src/datastore/v3/pg-store-v3.ts b/src/datastore/v3/pg-store-v3.ts index 836eed351..648b4c73a 100644 --- a/src/datastore/v3/pg-store-v3.ts +++ b/src/datastore/v3/pg-store-v3.ts @@ -11,7 +11,7 @@ import { DbPrincipalBondPosition, DbPrincipalFtBalance, DbPrincipalNftBalance, - DbPrincipalStakingBalances, + DbPrincipalStakingSummary, DbPrincipalTransactionSummary, DbTransaction, DbTransactionCursor, @@ -1160,22 +1160,15 @@ 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 principal's bond positions. + * @returns The staking summary. */ - async getPrincipalStakingBalances(args: { + async getPrincipalStakingSummary(args: { principal: Principal; - }): Promise { + }): Promise { return await this.sqlTransaction(async sql => { - const bonds = 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 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 @@ -1191,19 +1184,108 @@ export class PgStoreV3 extends BasePgStoreModule { LIMIT 1 `; const resolvedLock = resolveMaterializedStxLock(lockRow, tip?.burn_block_height ?? 0, null); - const [rewards] = await sql<{ accrued_rewards: string; claimed_rewards: string }[]>` + const [stxRewards] = await sql<{ accrued_rewards: string; claimed_rewards: string }[]>` SELECT accrued_rewards, claimed_rewards FROM principal_stx_staking_rewards WHERE principal = ${args.principal} LIMIT 1 `; + const [bondAgg] = await sql< + { + count: number; + btc_locked: string; + stx_locked: string; + accrued_rewards: string; + claimed_rewards: string; + }[] + >` + SELECT + COUNT(*)::int AS count, + COALESCE(SUM(btc_locked::numeric), 0)::text AS btc_locked, + COALESCE(SUM(stx_locked::numeric), 0)::text AS stx_locked, + COALESCE(SUM(accrued_rewards::numeric), 0)::text AS accrued_rewards, + COALESCE(SUM(claimed_rewards::numeric), 0)::text AS claimed_rewards + FROM principal_bond_positions + WHERE canonical = true + AND microblock_canonical = true + AND principal = ${args.principal} + `; return { - bonds, stx: { locked: resolvedLock.locked.toString(), - accrued_rewards: rewards?.accrued_rewards ?? '0', - claimed_rewards: rewards?.claimed_rewards ?? '0', + accrued_rewards: stxRewards?.accrued_rewards ?? '0', + claimed_rewards: stxRewards?.claimed_rewards ?? '0', + }, + bonds: { + count: bondAgg?.count ?? 0, + btc_locked: bondAgg?.btc_locked ?? '0', + stx_locked: bondAgg?.stx_locked ?? '0', + accrued_rewards: bondAgg?.accrued_rewards ?? '0', + claimed_rewards: bondAgg?.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 getPrincipalBondPositions(args: { + principal: Principal; + limit: number; + cursor?: BondCursor; + }): Promise> { + return await this.sqlTransaction(async sql => { + const cursorFilter = args.cursor ? sql`AND bond_index >= ${parseInt(args.cursor)}` : sql``; + const resultQuery = await sql<(DbPrincipalBondPosition & { total: number })[]>` + SELECT + ${sql(prefixedCols(PRINCIPAL_BOND_POSITION_COLUMNS, 'p'))}, + ( + SELECT COUNT(*)::int FROM principal_bond_positions + WHERE canonical = true AND microblock_canonical = true AND principal = ${args.principal} + ) AS total + 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 total = resultQuery.count > 0 ? resultQuery[0].total : 0; + + 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 a16872755..ff00c449d 100644 --- a/src/datastore/v3/types.ts +++ b/src/datastore/v3/types.ts @@ -205,10 +205,19 @@ export interface DbPrincipalStxStaking { claimed_rewards: string; } -/** A principal's full staking picture: its bond positions plus its STX-staking position. */ -export interface DbPrincipalStakingBalances { - bonds: DbPrincipalBondPosition[]; +/** 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 { diff --git a/tests/api/pox5/bonds.test.ts b/tests/api/pox5/bonds.test.ts index ecb5948d6..2f131119f 100644 --- a/tests/api/pox5/bonds.test.ts +++ b/tests/api/pox5/bonds.test.ts @@ -100,16 +100,17 @@ interface PrincipalBondPositionItem { bond_index: number; status: string; active: boolean; - balances: { - locked: { btc: string; stx: string }; - rewards: { btc: BtcRewardsItem }; - }; enrollment: { tx_id: string; btc_lockup: { amount: string } }; - amount: string; + locked: { btc: string; stx: string }; + rewards: { btc: BtcRewardsItem }; +} +interface BondPositionsPage { + total: number; + results: PrincipalBondPositionItem[]; } -interface StakingBalances { - bonds: 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(); @@ -313,19 +314,18 @@ describe('pox-5 bonds (simulated ingestion)', () => { }); }); - test("alice's position appears in GET .../principals/:principal/balances/staking", async () => { - const staking = 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 = staking.bonds.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.rewards.btc.accrued), 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)); @@ -565,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`)).bonds; - 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( @@ -596,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`)).bonds; - 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( @@ -625,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`) - ).bonds; - assert.equal(positionsRestored.length, 1, 'position restored'); + const summaryRestored = await getJson( + `/extended/v3/principals/${ALICE}/staking` + ); + 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 () => { @@ -735,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`) - ).bonds; - assert.equal(positions.length, 0, 'position orphaned'); + const summary = await getJson(`/extended/v3/principals/${ALICE}/staking`); + assert.equal(summary.bonds.count, 0, 'position orphaned'); }); }); @@ -766,7 +764,7 @@ describe('pox-5 bonds unstake / early-exit', () => { return pos; } const getPositions = async () => - (await getJson(`/extended/v3/principals/${ALICE}/balances/staking`)).bonds; + (await getJson(`/extended/v3/principals/${ALICE}/staking/bonds`)).results; const getBond = () => getJson(`/extended/v3/staking/bonds/${BOND_INDEX}`); beforeEach(async () => { @@ -813,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); }); @@ -840,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'); @@ -861,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'); }); @@ -886,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'); }); }); @@ -917,15 +915,15 @@ describe('pox-5 bonds reward accrual', () => { async function rewardsFor( principal: string ): Promise<{ accrued: bigint; claimed: bigint; claimable: bigint }> { - const staking = await getJson( - `/extended/v3/principals/${principal}/balances/staking` + const page = await getJson( + `/extended/v3/principals/${principal}/staking/bonds` ); - const pos = staking.bonds.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 { - accrued: BigInt(pos.balances.rewards.btc.accrued), - claimed: BigInt(pos.balances.rewards.btc.claimed), - claimable: BigInt(pos.balances.rewards.btc.claimable), + accrued: BigInt(pos.rewards.btc.accrued), + claimed: BigInt(pos.rewards.btc.claimed), + claimable: BigInt(pos.rewards.btc.claimable), }; } async function accruedFor(principal: string): Promise { @@ -1211,14 +1209,14 @@ describe('pox-5 STX-staking reward accrual', () => { return JSON.parse(res.text) as T; } async function stxRewardsFor(principal: string) { - const staking = await getJson( - `/extended/v3/principals/${principal}/balances/staking` + const summary = await getJson( + `/extended/v3/principals/${principal}/staking` ); return { - locked: BigInt(staking.stx.locked), - accrued: BigInt(staking.stx.rewards.btc.accrued), - claimed: BigInt(staking.stx.rewards.btc.claimed), - claimable: BigInt(staking.stx.rewards.btc.claimable), + 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) { @@ -1306,10 +1304,8 @@ describe('pox-5 STX-staking reward accrual', () => { assert.equal(alice.claimable, ALICE_EXPECTED); assert.equal(bob.accrued, BOB_EXPECTED); // No bond positions for either staker. - const staking = await getJson( - `/extended/v3/principals/${ALICE}/balances/staking` - ); - assert.equal(staking.bonds.length, 0, 'STX staking has no bond positions'); + 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 () => { @@ -1540,3 +1536,89 @@ describe('pox-5 signer reward claims', () => { 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' }); + }); +}); From bb7335881cf01020a6711690e262ee8f64d504b9 Mon Sep 17 00:00:00 2001 From: Rafa Cardenas <253999660+rafa-stacks@users.noreply.github.com> Date: Tue, 16 Jun 2026 23:26:15 -0600 Subject: [PATCH 6/7] dont use aggregate functions --- .../1779800000003_principal-staking-totals.ts | 71 ++++++++ ...800000003_principal-stx-staking-rewards.ts | 37 ---- ...0004_principal-stx-reward-distributions.ts | 2 +- .../1779800000005_signer-reward-claims.ts | 2 +- src/datastore/common.ts | 2 +- src/datastore/pg-write-store.ts | 160 +++++++++++++----- src/datastore/v3/pg-store-v3.ts | 66 ++++---- tests/api/pox5/bonds.test.ts | 80 +++++++++ 8 files changed, 302 insertions(+), 118 deletions(-) create mode 100644 migrations/1779800000003_principal-staking-totals.ts delete mode 100644 migrations/1779800000003_principal-stx-staking-rewards.ts 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/1779800000003_principal-stx-staking-rewards.ts b/migrations/1779800000003_principal-stx-staking-rewards.ts deleted file mode 100644 index bb069ea67..000000000 --- a/migrations/1779800000003_principal-stx-staking-rewards.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { ColumnDefinitions, MigrationBuilder } from 'node-pg-migrate'; - -export const shorthands: ColumnDefinitions | undefined = undefined; - -/** - * Running per-principal sBTC reward totals for pox-5 STX staking (the rewards a - * principal earns from locking STX, as opposed to participating in a bond). - * This is a pure materialized accumulator like `ft_balances` — one row per - * principal, no canonical/tx-location columns — fed incrementally on write and - * delta-corrected on reorg from the source rows in - * `principal_stx_reward_distributions` (accruals) and the NULL-`bond_index` - * rows of `principal_bond_reward_claims` (claims). Claimable rewards are - * `accrued_rewards - claimed_rewards`. - */ -export function up(pgm: MigrationBuilder): void { - pgm.createTable('principal_stx_staking_rewards', { - principal: { - type: 'text', - notNull: true, - primaryKey: true, - }, - accrued_rewards: { - type: 'numeric', - notNull: true, - default: 0, - }, - claimed_rewards: { - type: 'numeric', - notNull: true, - default: 0, - }, - }); -} - -export function down(pgm: MigrationBuilder): void { - pgm.dropTable('principal_stx_staking_rewards'); -} diff --git a/migrations/1779800000004_principal-stx-reward-distributions.ts b/migrations/1779800000004_principal-stx-reward-distributions.ts index adae01fa7..acc8dc49a 100644 --- a/migrations/1779800000004_principal-stx-reward-distributions.ts +++ b/migrations/1779800000004_principal-stx-reward-distributions.ts @@ -8,7 +8,7 @@ export const shorthands: ColumnDefinitions | undefined = undefined; * 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_stx_staking_rewards.accrued_rewards` total under reorgs — analogous + * `principal_staking_totals.stx_accrued_rewards` total under reorgs — analogous * to `principal_bond_reward_distributions` for bond rewards. */ export function up(pgm: MigrationBuilder): void { diff --git a/migrations/1779800000005_signer-reward-claims.ts b/migrations/1779800000005_signer-reward-claims.ts index 59bd6abad..9f090b767 100644 --- a/migrations/1779800000005_signer-reward-claims.ts +++ b/migrations/1779800000005_signer-reward-claims.ts @@ -9,7 +9,7 @@ export const shorthands: ColumnDefinitions | undefined = undefined; * 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_stx_staking_rewards` — so reorgs only flip its canonical flag. + * `principal_staking_totals` — so reorgs only flip its canonical flag. */ export function up(pgm: MigrationBuilder): void { pgm.createTable('signer_reward_claims', { diff --git a/src/datastore/common.ts b/src/datastore/common.ts index 0001821e7..33ba5e814 100644 --- a/src/datastore/common.ts +++ b/src/datastore/common.ts @@ -1746,7 +1746,7 @@ export interface DbPrincipalBondRewardClaimInsertValues extends DbTxLocation { * 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_stx_staking_rewards.accrued_rewards` + * weight). Backs the running `principal_staking_totals.stx_accrued_rewards` * total under reorgs. */ export interface DbPrincipalStxRewardDistributionInsertValues extends DbTxLocation { diff --git a/src/datastore/pg-write-store.ts b/src/datastore/pg-write-store.ts index 03725f8b6..3c464ed4e 100644 --- a/src/datastore/pg-write-store.ts +++ b/src/datastore/pg-write-store.ts @@ -754,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; } @@ -836,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; } @@ -909,6 +931,12 @@ 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 + `; } } @@ -939,10 +967,10 @@ export class PgWriteStore extends PgStore { // STX-staking reward claim (no bond): roll into the staker's running // STX-staking claimed total. await sql` - INSERT INTO principal_stx_staking_rewards (principal, claimed_rewards) + INSERT INTO principal_staking_totals (principal, stx_claimed_rewards) VALUES (${event.data.staker}, ${event.data.rewards_claimed}::numeric) ON CONFLICT (principal) DO UPDATE - SET claimed_rewards = principal_stx_staking_rewards.claimed_rewards + EXCLUDED.claimed_rewards + SET stx_claimed_rewards = principal_staking_totals.stx_claimed_rewards + EXCLUDED.stx_claimed_rewards `; return; } @@ -954,6 +982,12 @@ export class PgWriteStore extends PgStore { 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 + `; } /** @@ -1033,10 +1067,10 @@ export class PgWriteStore extends PgStore { } for (const s of stakerRewards) { await sql` - INSERT INTO principal_stx_staking_rewards (principal, accrued_rewards) + INSERT INTO principal_staking_totals (principal, stx_accrued_rewards) VALUES (${s.principal}, ${s.reward_amount}::numeric) ON CONFLICT (principal) DO UPDATE - SET accrued_rewards = principal_stx_staking_rewards.accrued_rewards + EXCLUDED.accrued_rewards + SET stx_accrued_rewards = principal_staking_totals.stx_accrued_rewards + EXCLUDED.stx_accrued_rewards `; } } @@ -4512,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 () => { @@ -4545,11 +4597,23 @@ 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 ) - 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 + 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 () => { @@ -4568,15 +4632,27 @@ export class PgWriteStore extends PgStore { 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 ) - 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 + 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_rewards total. The - // rows are now at `canonical`, so the direction follows that flag. + // 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 @@ -4586,9 +4662,9 @@ export class PgWriteStore extends PgStore { AND bond_index IS NULL GROUP BY principal ) - UPDATE principal_stx_staking_rewards p - SET claimed_rewards = ${ - canonical ? sql`p.claimed_rewards + c.total` : sql`p.claimed_rewards - c.total` + 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 @@ -4610,8 +4686,8 @@ export class PgWriteStore extends PgStore { FROM updated GROUP BY principal ) - UPDATE principal_stx_staking_rewards 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 `; diff --git a/src/datastore/v3/pg-store-v3.ts b/src/datastore/v3/pg-store-v3.ts index 648b4c73a..4fde08fde 100644 --- a/src/datastore/v3/pg-store-v3.ts +++ b/src/datastore/v3/pg-store-v3.ts @@ -1184,43 +1184,36 @@ export class PgStoreV3 extends BasePgStoreModule { LIMIT 1 `; const resolvedLock = resolveMaterializedStxLock(lockRow, tip?.burn_block_height ?? 0, null); - const [stxRewards] = await sql<{ accrued_rewards: string; claimed_rewards: string }[]>` - SELECT accrued_rewards, claimed_rewards FROM principal_stx_staking_rewards - WHERE principal = ${args.principal} - LIMIT 1 - `; - const [bondAgg] = await sql< + // The staking summary is materialized — a single-row lookup, no aggregates. + const [totals] = await sql< { - count: number; - btc_locked: string; - stx_locked: string; - accrued_rewards: string; - claimed_rewards: string; + 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 - COUNT(*)::int AS count, - COALESCE(SUM(btc_locked::numeric), 0)::text AS btc_locked, - COALESCE(SUM(stx_locked::numeric), 0)::text AS stx_locked, - COALESCE(SUM(accrued_rewards::numeric), 0)::text AS accrued_rewards, - COALESCE(SUM(claimed_rewards::numeric), 0)::text AS claimed_rewards - FROM principal_bond_positions - WHERE canonical = true - AND microblock_canonical = true - AND principal = ${args.principal} + 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: stxRewards?.accrued_rewards ?? '0', - claimed_rewards: stxRewards?.claimed_rewards ?? '0', + accrued_rewards: totals?.stx_accrued_rewards ?? '0', + claimed_rewards: totals?.stx_claimed_rewards ?? '0', }, bonds: { - count: bondAgg?.count ?? 0, - btc_locked: bondAgg?.btc_locked ?? '0', - stx_locked: bondAgg?.stx_locked ?? '0', - accrued_rewards: bondAgg?.accrued_rewards ?? '0', - claimed_rewards: bondAgg?.claimed_rewards ?? '0', + 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', }, }; }); @@ -1237,14 +1230,16 @@ export class PgStoreV3 extends BasePgStoreModule { cursor?: BondCursor; }): Promise> { return await this.sqlTransaction(async sql => { + // 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<(DbPrincipalBondPosition & { total: number })[]>` - SELECT - ${sql(prefixedCols(PRINCIPAL_BOND_POSITION_COLUMNS, 'p'))}, - ( - SELECT COUNT(*)::int FROM principal_bond_positions - WHERE canonical = true AND microblock_canonical = true AND principal = ${args.principal} - ) AS total + 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 @@ -1256,7 +1251,6 @@ export class PgStoreV3 extends BasePgStoreModule { const hasNextPage = resultQuery.count > args.limit; const results = hasNextPage ? resultQuery.slice(0, args.limit) : resultQuery; - const total = resultQuery.count > 0 ? resultQuery[0].total : 0; const nextResult = resultQuery[resultQuery.length - 1]; const nextCursor = hasNextPage && nextResult ? `${nextResult.bond_index}` : null; diff --git a/tests/api/pox5/bonds.test.ts b/tests/api/pox5/bonds.test.ts index 2f131119f..fd111e64c 100644 --- a/tests/api/pox5/bonds.test.ts +++ b/tests/api/pox5/bonds.test.ts @@ -1179,6 +1179,78 @@ describe('pox-5 bonds reward accrual', () => { 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'); + }); }); /** @@ -1621,4 +1693,12 @@ describe('pox-5 principal bond positions pagination', () => { 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); + }); }); From 64e43cb4ba92825f618819e74e4ec7bace2a9b35 Mon Sep 17 00:00:00 2001 From: Rafa Cardenas <253999660+rafa-stacks@users.noreply.github.com> Date: Wed, 17 Jun 2026 10:25:03 -0600 Subject: [PATCH 7/7] style nits --- openapi.yaml | 415 ++------------------------------ src/api/serializers/v3/bonds.ts | 20 +- 2 files changed, 26 insertions(+), 409 deletions(-) diff --git a/openapi.yaml b/openapi.yaml index 41a026d71..f1d34b003 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -4,7 +4,7 @@ info: description: Welcome to the API reference overview for the [Stacks Blockchain API](https://docs.hiro.so/stacks-blockchain-api). [Download Postman collection](https://hirosystems.github.io/stacks-blockchain-api/collection.json). - version: 9.0.0-pox5.7 + version: 9.0.0-pox5.8 components: schemas: {} paths: @@ -80294,7 +80294,7 @@ paths: [Stacks Explorer](https://explorer.hiro.so/?chain=testnet). The tokens are delivered once the transaction has been included in a block. - **Note:** This is a testnet only endpoint. This endpoint will not work on the mainnet. + **Note:** This is a testnet only endpoint. This endpoint will not work on mainnet. parameters: - schema: type: string @@ -94061,7 +94061,7 @@ paths: type: string /extended/v3/principals/{principal}/balances/staking: get: - operationId: get_principal_staking_balances + operationId: get_principal_balances_staking summary: Get principal staking balances tags: - Staking @@ -94101,6 +94101,7 @@ paths: - balances - enrollment - amount + - accrued_rewards properties: bond_index: description: The index of the bond in the PoX-5 bond list @@ -94126,7 +94127,7 @@ paths: type: object required: - locked - - rewards + - paid_out properties: locked: type: object @@ -94135,32 +94136,19 @@ paths: - stx properties: btc: - description: The amount of BTC that is locked up for this position + description: The total amount of BTC that is locked up for this bond type: string stx: - description: The amount of STX that is locked up for this position + description: The total amount of STX that is locked up for this bond type: string - rewards: + paid_out: type: object required: - btc properties: btc: - type: object - required: - - accrued - - claimed - - claimable - properties: - accrued: - description: The lifetime sBTC reward sats accrued to this position - type: string - claimed: - description: The lifetime sBTC reward sats already claimed against this position - type: string - claimable: - description: The sBTC reward sats currently claimable (accrued minus claimed) - type: string + description: The total amount of BTC that has been paid out for this bond + type: string enrollment: type: object required: @@ -94184,6 +94172,9 @@ paths: amount: description: The amount of STX that is locked up for this principal type: string + accrued_rewards: + description: The sBTC reward sats accrued to this participant's position + type: string 4XX: description: Default Response content: @@ -94201,381 +94192,7 @@ paths: type: string /extended/v3/principals/{principal}/balances/stx: get: - operationId: get_principal_stx_balance - summary: Get principal STX balance - tags: - - Accounts - description: "Get a principal's STX balance: its total and spendable (available) - balance, any locked STX, and the projected balance from pending mempool - transactions." - parameters: - - schema: - anyOf: - - pattern: ^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{28,41} - title: Stacks Address - description: Stacks Address - examples: - - SP318Q55DEKHRXJK696033DQN5C54D9K2EE6DHRWP - type: string - - pattern: ^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{28,41}.[a-zA-Z]([a-zA-Z0-9]|[-_]){0,39}$ - title: Smart Contract ID - description: Smart Contract ID - examples: - - SP000000000000000000002Q6VF78.pox-3 - type: string - in: path - name: principal - required: true - responses: - "200": - description: Default Response - content: - application/json: - schema: - title: PrincipalStxBalance - type: object - required: - - balance - - available - - locked - - mempool - properties: - balance: - description: Total micro-STX balance (available plus locked) - type: string - available: - description: Spendable micro-STX balance (balance minus locked) - type: string - locked: - anyOf: - - title: StxLock - type: object - required: - - amount - - pox_version - - lock_tx_id - - stacks_lock_height - - burn_lock_height - - burn_unlock_height - properties: - amount: - description: The amount of locked micro-STX - type: string - pox_version: - description: The PoX contract version that created the lock (e.g. 4, 5) - type: integer - lock_tx_id: - pattern: ^(0x)?[a-fA-F0-9]{64}$ - title: Transaction ID - description: Transaction ID - examples: - - "0xf6bd5f4a7b26184a3466340b2e99fd003b4962c0e382\ - a7e4b6a13df3dd7a91c6" - type: string - stacks_lock_height: - description: The Stacks block height at which the lock was created - type: integer - burn_lock_height: - description: The burnchain block height at which the lock was created - type: integer - burn_unlock_height: - description: The burnchain block height at which the locked STX unlocks - type: integer - - type: "null" - mempool: - anyOf: - - title: StxMempoolBalance - type: object - required: - - estimated_balance - - inbound - - outbound - properties: - estimated_balance: - description: Estimated spendable micro-STX balance once pending mempool txs - confirm - type: string - inbound: - description: Pending inbound micro-STX from the mempool - type: string - outbound: - description: Pending outbound micro-STX from the mempool (transfers plus fees) - type: string - - type: "null" - 4XX: - description: Default Response - content: - application/json: - schema: - title: Error Response - additionalProperties: true - type: object - required: - - error - properties: - error: - type: string - message: - type: string - /extended/v3/principals/{principal}/balances/ft: - get: - operationId: get_principal_ft_balances - summary: Get principal FT balances - tags: - - Accounts - description: Get a principal's fungible-token balances, sorted by balance descending. - parameters: - - schema: - minimum: 1 - default: 100 - maximum: 200 - type: integer - in: query - name: limit - required: false - description: Number of results per page - - schema: - pattern: ^\d+:.+$ - type: string - in: query - name: cursor - required: false - description: "Cursor for paginating FT balances (sorted by balance, descending). - Format: balance:asset_identifier" - - schema: - anyOf: - - pattern: ^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{28,41} - title: Stacks Address - description: Stacks Address - examples: - - SP318Q55DEKHRXJK696033DQN5C54D9K2EE6DHRWP - type: string - - pattern: ^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{28,41}.[a-zA-Z]([a-zA-Z0-9]|[-_]){0,39}$ - title: Smart Contract ID - description: Smart Contract ID - examples: - - SP000000000000000000002Q6VF78.pox-3 - type: string - in: path - name: principal - required: true - responses: - "200": - description: Default Response - content: - application/json: - schema: - type: object - required: - - total - - limit - - cursor - - results - properties: - total: - type: integer - example: 1 - limit: - minimum: 1 - default: 100 - maximum: 200 - description: Number of results per page - type: integer - cursor: - type: object - required: - - next - - previous - - current - properties: - next: - anyOf: - - pattern: ^\d+:.+$ - description: "Cursor for paginating FT balances (sorted by balance, descending). - Format: balance:asset_identifier" - type: string - - type: "null" - previous: - anyOf: - - pattern: ^\d+:.+$ - description: "Cursor for paginating FT balances (sorted by balance, descending). - Format: balance:asset_identifier" - type: string - - type: "null" - current: - anyOf: - - pattern: ^\d+:.+$ - description: "Cursor for paginating FT balances (sorted by balance, descending). - Format: balance:asset_identifier" - type: string - - type: "null" - results: - type: array - items: - title: PrincipalFtPosition - type: object - required: - - asset_identifier - - balance - properties: - asset_identifier: - description: Fungible token asset identifier - type: string - example: SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token::sbtc-token - balance: - description: The principal's balance of this token, as a string-quoted integer - in base units - type: string - 4XX: - description: Default Response - content: - application/json: - schema: - title: Error Response - additionalProperties: true - type: object - required: - - error - properties: - error: - type: string - message: - type: string - /extended/v3/principals/{principal}/balances/nft: - get: - operationId: get_principal_nft_balances - summary: Get principal NFT balances - tags: - - Accounts - description: Get the non-fungible token instances currently owned by a - principal, ordered by asset identifier and value. - parameters: - - schema: - minimum: 1 - default: 100 - maximum: 200 - type: integer - in: query - name: limit - required: false - description: Number of results per page - - schema: - pattern: ^0x[0-9a-fA-F]*:.+$ - type: string - in: query - name: cursor - required: false - description: "Cursor for paginating NFT balances (sorted by asset identifier - then value). Format: value:asset_identifier" - - schema: - anyOf: - - pattern: ^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{28,41} - title: Stacks Address - description: Stacks Address - examples: - - SP318Q55DEKHRXJK696033DQN5C54D9K2EE6DHRWP - type: string - - pattern: ^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{28,41}.[a-zA-Z]([a-zA-Z0-9]|[-_]){0,39}$ - title: Smart Contract ID - description: Smart Contract ID - examples: - - SP000000000000000000002Q6VF78.pox-3 - type: string - in: path - name: principal - required: true - responses: - "200": - description: Default Response - content: - application/json: - schema: - type: object - required: - - total - - limit - - cursor - - results - properties: - total: - type: integer - example: 1 - limit: - minimum: 1 - default: 100 - maximum: 200 - description: Number of results per page - type: integer - cursor: - type: object - required: - - next - - previous - - current - properties: - next: - anyOf: - - pattern: ^0x[0-9a-fA-F]*:.+$ - description: "Cursor for paginating NFT balances (sorted by asset identifier - then value). Format: value:asset_identifier" - type: string - - type: "null" - previous: - anyOf: - - pattern: ^0x[0-9a-fA-F]*:.+$ - description: "Cursor for paginating NFT balances (sorted by asset identifier - then value). Format: value:asset_identifier" - type: string - - type: "null" - current: - anyOf: - - pattern: ^0x[0-9a-fA-F]*:.+$ - description: "Cursor for paginating NFT balances (sorted by asset identifier - then value). Format: value:asset_identifier" - type: string - - type: "null" - results: - type: array - items: - title: PrincipalNftPosition - type: object - required: - - asset_identifier - - value - properties: - asset_identifier: - description: Non-fungible token asset identifier - type: string - example: SP2X0TZ59D5SZ8ACQ6YMCHHNR2ZN51Z32E2CJ173.the-explorer-guild::The-Explorer-Guild - value: - description: The NFT instance identifier, as a Clarity value - type: object - required: - - hex - - repr - properties: - hex: - type: string - repr: - type: string - 4XX: - description: Default Response - content: - application/json: - schema: - title: Error Response - additionalProperties: true - type: object - required: - - error - properties: - error: - type: string - message: - type: string - /extended/v3/principals/{principal}/balances/stx: - get: - operationId: get_principal_stx_balance + operationId: get_principal_balances_stx summary: Get principal STX balance tags: - Accounts @@ -94693,7 +94310,7 @@ paths: type: string /extended/v3/principals/{principal}/balances/ft: get: - operationId: get_principal_ft_balances + operationId: get_principal_balances_ft summary: Get principal FT balances tags: - Accounts @@ -94817,7 +94434,7 @@ paths: type: string /extended/v3/principals/{principal}/balances/nft: get: - operationId: get_principal_nft_balances + operationId: get_principal_balances_nft summary: Get principal NFT balances tags: - Accounts diff --git a/src/api/serializers/v3/bonds.ts b/src/api/serializers/v3/bonds.ts index 7dac1086f..3f4a8d47d 100644 --- a/src/api/serializers/v3/bonds.ts +++ b/src/api/serializers/v3/bonds.ts @@ -21,7 +21,7 @@ import { } from '../../schemas/v3/entities/principal-bond-positions.js'; /** Build the `{ accrued, claimed, claimable }` sBTC reward triple from running totals. */ -function btcRewards(accrued: string, claimed: string): BtcRewards { +function serializeBtcRewards(accrued: string, claimed: string): BtcRewards { return { accrued, claimed, @@ -29,7 +29,7 @@ function btcRewards(accrued: string, claimed: string): BtcRewards { }; } -function getBondStatus(summary: DbBondSummary, currentBurnBlockHeight: number): BondStatus { +function serializeBondStatus(summary: DbBondSummary, currentBurnBlockHeight: number): BondStatus { if (currentBurnBlockHeight < summary.bond_start_height) { return 'upcoming'; } @@ -51,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, @@ -116,7 +116,7 @@ export function serializeDbBondAllowlistEntry(entry: DbBondAllowlistEntry): Bond }; } -function getPrincipalBondPositionStatus( +function serializePrincipalBondPositionStatus( status: DbPrincipalBondPositionStatus ): PrincipalBondPositionStatus { switch (status) { @@ -131,7 +131,7 @@ function getPrincipalBondPositionStatus( } } -function getBondLockupType(type: DbBondLockupType): 'l1' | 'l2' { +function serializeBondLockupType(type: DbBondLockupType): 'l1' | 'l2' { switch (type) { case DbBondLockupType.L1: return 'l1'; @@ -145,7 +145,7 @@ export function serializeDbPrincipalBondPosition( ): PrincipalBondPosition { return { bond_index: position.bond_index, - status: getPrincipalBondPositionStatus(position.status), + status: serializePrincipalBondPositionStatus(position.status), active: position.active, enrollment: { tx_id: position.tx_id, @@ -158,7 +158,7 @@ export function serializeDbPrincipalBondPosition( stx: position.stx_locked, }, rewards: { - btc: btcRewards(position.accrued_rewards, position.claimed_rewards), + btc: serializeBtcRewards(position.accrued_rewards, position.claimed_rewards), }, }; } @@ -170,7 +170,7 @@ export function serializeDbPrincipalStakingSummary( stx: { locked: summary.stx.locked, rewards: { - btc: btcRewards(summary.stx.accrued_rewards, summary.stx.claimed_rewards), + btc: serializeBtcRewards(summary.stx.accrued_rewards, summary.stx.claimed_rewards), }, }, bonds: { @@ -180,7 +180,7 @@ export function serializeDbPrincipalStakingSummary( stx: summary.bonds.stx_locked, }, rewards: { - btc: btcRewards(summary.bonds.accrued_rewards, summary.bonds.claimed_rewards), + btc: serializeBtcRewards(summary.bonds.accrued_rewards, summary.bonds.claimed_rewards), }, }, }; @@ -192,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,