From f374dda564ebb1e1ae642887e6a5130c5049a088 Mon Sep 17 00:00:00 2001 From: Rafa Cardenas <253999660+rafa-stacks@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:08:14 -0600 Subject: [PATCH 1/3] migration and ingestion --- migrations/1779800000006_staking-signers.ts | 46 ++++ src/api/init.ts | 2 + src/api/routes/v3/staking-signers.ts | 56 ++++ src/api/schemas/v3/cursors.ts | 7 + .../schemas/v3/entities/staking-signers.ts | 21 ++ src/datastore/pg-write-store.ts | 74 +++++- src/datastore/v3/constants.ts | 8 + src/datastore/v3/pg-store-v3.ts | 63 +++++ src/datastore/v3/types.ts | 9 + tests/api/pox5/signers.test.ts | 241 ++++++++++++++++++ 10 files changed, 524 insertions(+), 3 deletions(-) create mode 100644 migrations/1779800000006_staking-signers.ts create mode 100644 src/api/routes/v3/staking-signers.ts create mode 100644 src/api/schemas/v3/entities/staking-signers.ts create mode 100644 tests/api/pox5/signers.test.ts diff --git a/migrations/1779800000006_staking-signers.ts b/migrations/1779800000006_staking-signers.ts new file mode 100644 index 000000000..8705a36fe --- /dev/null +++ b/migrations/1779800000006_staking-signers.ts @@ -0,0 +1,46 @@ +import type { ColumnDefinitions, MigrationBuilder } from 'node-pg-migrate'; + +export const shorthands: ColumnDefinitions | undefined = undefined; + +/** + * Materialized pox-5 staking signer registry — one row per signer principal, + * holding its currently-registered signing key. Mirrors the contract's + * `(define-map signers principal (buff 33))`: `register-signer` does a + * `map-set` keyed by the signer principal, so re-registering overwrites the + * key (latest-wins). This is a current-state accumulator like + * `stx_locked_balances` (no canonical column) — upserted on write and + * recomputed for affected signers on reorg from the canonical `register-signer` + * rows in `pox5_events`. + */ +export function up(pgm: MigrationBuilder): void { + pgm.createTable('staking_signers', { + // The signer principal (the signer-manager contract that registered itself). + signer: { + type: 'text', + notNull: true, + primaryKey: true, + }, + // The registered compressed secp256k1 public key (33 bytes). + signer_key: { + type: 'bytea', + notNull: true, + }, + // Provenance of the registration. + tx_id: { + type: 'bytea', + notNull: true, + }, + block_height: { + type: 'integer', + notNull: true, + }, + burn_block_height: { + type: 'integer', + notNull: true, + }, + }); +} + +export function down(pgm: MigrationBuilder): void { + pgm.dropTable('staking_signers'); +} diff --git a/src/api/init.ts b/src/api/init.ts index 4ca429290..404669bfb 100644 --- a/src/api/init.ts +++ b/src/api/init.ts @@ -54,6 +54,7 @@ import { TransactionsRoutes } from './routes/v3/transactions.js'; import { MempoolRoutes } from './routes/v3/mempool.js'; import { BlocksRoutes } from './routes/v3/blocks.js'; import { StakingBondsRoutes } from './routes/v3/staking-bonds.js'; +import { StakingSignersRoutes } from './routes/v3/staking-signers.js'; export interface ApiServer { fastifyApp: FastifyInstance; @@ -110,6 +111,7 @@ export const StacksApiRoutes: FastifyPluginAsync< await fastify.register(MempoolRoutes); await fastify.register(PrincipalsRoutes); await fastify.register(StakingBondsRoutes); + await fastify.register(StakingSignersRoutes); await fastify.register(TransactionsRoutes); }, { prefix: '/extended/v3' } diff --git a/src/api/routes/v3/staking-signers.ts b/src/api/routes/v3/staking-signers.ts new file mode 100644 index 000000000..a5e650f15 --- /dev/null +++ b/src/api/routes/v3/staking-signers.ts @@ -0,0 +1,56 @@ +import { FastifyPluginAsync } from 'fastify'; +import { Server } from 'node:http'; +import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; +import { handleChainTipCache } from '../../controllers/cache-controller.js'; +import { getPagingQueryLimit, ResourceType } from '../../pagination.js'; +import { + CursorPaginatedResponse, + CursorPaginationQuerystring, + SignerCursorSchema, +} from '../../schemas/v3/cursors.js'; +import { StakingSignerSchema } from '../../schemas/v3/entities/staking-signers.js'; + +export const StakingSignersRoutes: FastifyPluginAsync< + Record, + Server, + TypeBoxTypeProvider +> = async fastify => { + fastify.get( + '/staking/signers', + { + preHandler: handleChainTipCache, + schema: { + operationId: 'get_staking_signers', + summary: 'Get staking signers', + description: 'Get the registered pox-5 staking signers and their current signing keys.', + tags: ['Staking'], + querystring: CursorPaginationQuerystring(SignerCursorSchema, ResourceType.Signer), + response: { + 200: CursorPaginatedResponse( + StakingSignerSchema, + SignerCursorSchema, + ResourceType.Signer + ), + }, + }, + }, + async (req, reply) => { + const results = await fastify.db.v3.getStakingSigners({ + limit: req.query.limit ?? getPagingQueryLimit(ResourceType.Signer), + 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, + }); + } + ); + + await Promise.resolve(); +}; diff --git a/src/api/schemas/v3/cursors.ts b/src/api/schemas/v3/cursors.ts index 0c39e885c..32e4ceb28 100644 --- a/src/api/schemas/v3/cursors.ts +++ b/src/api/schemas/v3/cursors.ts @@ -91,3 +91,10 @@ export const NftBalanceCursorSchema = Type.String({ 'Cursor for paginating NFT balances (sorted by asset identifier then value). Format: value:asset_identifier', }); export type NftBalanceCursor = Static; + +export const SignerCursorSchema = Type.String({ + // A Stacks principal: a standard address, optionally followed by a contract name. + pattern: '^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{28,41}(\\.[a-zA-Z]([a-zA-Z0-9]|[-_]){0,39})?$', + description: 'Cursor for paginating staking signers (sorted by signer). Format: signer principal', +}); +export type SignerCursor = Static; diff --git a/src/api/schemas/v3/entities/staking-signers.ts b/src/api/schemas/v3/entities/staking-signers.ts new file mode 100644 index 000000000..373c7ff69 --- /dev/null +++ b/src/api/schemas/v3/entities/staking-signers.ts @@ -0,0 +1,21 @@ +import { Static, Type } from '@sinclair/typebox'; +import { PrincipalSchema } from './common.js'; + +export const StakingSignerSchema = Type.Object( + { + signer: PrincipalSchema, + signer_key: Type.String({ + description: 'The registered compressed secp256k1 public key, as a `0x`-prefixed hex string', + examples: ['0x03a0f9e1...'], + }), + tx_id: Type.String({ description: 'The transaction that registered this signer key' }), + block_height: Type.Integer({ + description: 'The Stacks block height at which the signer key was registered', + }), + burn_block_height: Type.Integer({ + description: 'The burnchain block height at which the signer key was registered', + }), + }, + { title: 'StakingSigner' } +); +export type StakingSigner = Static; diff --git a/src/datastore/pg-write-store.ts b/src/datastore/pg-write-store.ts index 3c464ed4e..f46a835b2 100644 --- a/src/datastore/pg-write-store.ts +++ b/src/datastore/pg-write-store.ts @@ -122,6 +122,7 @@ import { Pox5EventClaimStakerRewardsForSigner, Pox5EventName, Pox5EventRegisterForBond, + Pox5EventRegisterSigner, Pox5EventSetupBond, Pox5EventStake, Pox5EventStakeUpdate, @@ -602,6 +603,8 @@ export class PgWriteStore extends PgStore { await this.clearStxLockedBalance(sql, poxEvent.data.staker); break; case Pox5EventName.RegisterSigner: + await this.upsertStakingSigner(sql, txLocation, poxEvent); + break; case Pox5EventName.AllowContractCaller: case Pox5EventName.DisallowContractCaller: case Pox5EventName.GrantSignerKey: @@ -1135,6 +1138,70 @@ export class PgWriteStore extends PgStore { await sql`DELETE FROM stx_locked_balances WHERE principal = ${principal}`; } + /** + * Materialize a pox-5 signer's currently-registered key (pox-5 + * `register-signer`). The contract keys its `signers` map by the signer + * principal and overwrites on re-registration, so this is a latest-wins + * upsert keyed by `signer`. + */ + private async upsertStakingSigner( + sql: PgSqlClient, + txLocation: DbTxLocation, + event: Pox5EventRegisterSigner + ) { + await sql` + INSERT INTO staking_signers (signer, signer_key, tx_id, block_height, burn_block_height) + VALUES ( + ${event.data.signer}, ${event.data.signer_key}, ${txLocation.tx_id}, + ${txLocation.block_height}, ${txLocation.burn_block_height} + ) + ON CONFLICT (signer) DO UPDATE SET + signer_key = EXCLUDED.signer_key, + tx_id = EXCLUDED.tx_id, + block_height = EXCLUDED.block_height, + burn_block_height = EXCLUDED.burn_block_height + `; + } + + /** + * Recompute the materialized `staking_signers` rows for every signer touched + * by a block whose canonical flag just flipped during a reorg. A signer's + * registered key is a latest-wins value (not additive), so we re-derive it + * from the latest canonical `register-signer` event in `pox5_events`. Must run + * after the `pox5_events` canonical flips for this block have completed. + */ + private async recomputeStakingSigners(sql: PgSqlClient, indexBlockHash: string) { + const affectedRows = await sql<{ signer: string }[]>` + SELECT data->>'signer' AS signer + FROM pox5_events + WHERE index_block_hash = ${indexBlockHash} AND name = 'register-signer' + `; + const signers = affectedRows.map(r => r.signer); + if (signers.length === 0) { + return; + } + for (const batch of batchIterate(signers, INSERT_BATCH_SIZE)) { + await sql` + DELETE FROM staking_signers WHERE signer IN ${sql(batch)} + `; + await sql` + INSERT INTO staking_signers (signer, signer_key, tx_id, block_height, burn_block_height) + SELECT DISTINCT ON (signer) + data->>'signer' AS signer, + decode(substr(data->>'signer_key', 3), 'hex') AS signer_key, + tx_id, + block_height, + burn_block_height + FROM pox5_events + WHERE canonical = true AND microblock_canonical = true + AND name = 'register-signer' + AND data->>'signer' IN ${sql(batch)} + ORDER BY signer, + block_height DESC, microblock_sequence DESC, tx_index DESC, event_index DESC + `; + } + } + /** * Recompute the materialized `stx_locked_balances` rows for every principal * touched by a block whose canonical flag just flipped during a reorg. @@ -4817,10 +4884,11 @@ export class PgWriteStore extends PgStore { await q.done(); - // Recompute the materialized locked-STX balances for principals touched by - // this block. Must run after the queue drains so the `stx_lock_events` and - // `pox5_events` canonical flips above are visible to the recompute. + // Recompute the materialized locked-STX balances and staking signer registry + // for entities touched by this block. Must run after the queue drains so the + // `stx_lock_events` / `pox5_events` canonical flips above are visible. await this.recomputeStxLockedBalances(sql, indexBlockHash); + await this.recomputeStakingSigners(sql, indexBlockHash); return result; } diff --git a/src/datastore/v3/constants.ts b/src/datastore/v3/constants.ts index 5e4d97b1e..0787b0033 100644 --- a/src/datastore/v3/constants.ts +++ b/src/datastore/v3/constants.ts @@ -158,3 +158,11 @@ export const PRINCIPAL_BOND_POSITION_COLUMNS = [ 'claimed_rewards', 'tx_id', ]; + +export const STAKING_SIGNER_COLUMNS = [ + 'signer', + 'signer_key', + 'tx_id', + 'block_height', + 'burn_block_height', +]; diff --git a/src/datastore/v3/pg-store-v3.ts b/src/datastore/v3/pg-store-v3.ts index 4fde08fde..deb80f94c 100644 --- a/src/datastore/v3/pg-store-v3.ts +++ b/src/datastore/v3/pg-store-v3.ts @@ -13,6 +13,7 @@ import { DbPrincipalNftBalance, DbPrincipalStakingSummary, DbPrincipalTransactionSummary, + DbStakingSigner, DbTransaction, DbTransactionCursor, DbTransactionEvent, @@ -27,6 +28,7 @@ import { MEMPOOL_TX_COLUMNS, MEMPOOL_TX_SUMMARY_COLUMNS, PRINCIPAL_BOND_POSITION_COLUMNS, + STAKING_SIGNER_COLUMNS, TX_COLUMNS, TX_SUMMARY_COLUMNS, } from './constants.js'; @@ -40,6 +42,7 @@ import type { BondCursor, FtBalanceCursor, NftBalanceCursor, + SignerCursor, TransactionCursor, TransactionEventCursor, } from '../../api/schemas/v3/cursors.js'; @@ -1282,4 +1285,64 @@ export class PgStoreV3 extends BasePgStoreModule { }; }); } + + /** + * Gets the registered pox-5 staking signers, cursor-paginated by `signer`. + * @param args - The arguments for the query. + * @returns The registered signers. + */ + async getStakingSigners(args: { + limit: number; + cursor?: SignerCursor; + }): Promise> { + return await this.sqlTransaction(async sql => { + const cursorFilter = args.cursor ? sql`WHERE signer >= ${args.cursor}` : sql``; + const resultQuery = await sql<(DbStakingSigner & { total: number })[]>` + SELECT + ${sql(STAKING_SIGNER_COLUMNS)}, + (SELECT COUNT(*)::int FROM staking_signers) AS total + FROM staking_signers + ${cursorFilter} + ORDER BY signer 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.signer : null; + const firstResult = results[0]; + const currentCursor = firstResult ? firstResult.signer : null; + + let prevCursor: string | null = null; + if (firstResult) { + const prevPageQuery = await sql<{ signer: string }[]>` + SELECT signer FROM staking_signers + WHERE signer < ${firstResult.signer} + ORDER BY signer DESC + LIMIT ${args.limit} + `; + if (prevPageQuery.length > 0) { + prevCursor = prevPageQuery[prevPageQuery.length - 1].signer; + } + } + + return { + limit: args.limit, + next_cursor: nextCursor, + prev_cursor: prevCursor, + current_cursor: currentCursor, + total, + results: results.map(r => ({ + signer: r.signer, + signer_key: r.signer_key, + tx_id: r.tx_id, + block_height: r.block_height, + burn_block_height: r.burn_block_height, + })), + }; + }); + } } diff --git a/src/datastore/v3/types.ts b/src/datastore/v3/types.ts index ff00c449d..6c6bd41bd 100644 --- a/src/datastore/v3/types.ts +++ b/src/datastore/v3/types.ts @@ -226,6 +226,15 @@ export interface DbTransactionCursor { tx_index: number; } +export interface DbStakingSigner { + signer: string; + /** The registered compressed secp256k1 public key as a `0x`-prefixed hex string. */ + signer_key: string; + tx_id: string; + block_height: number; + burn_block_height: number; +} + export interface DbPrincipalFtBalance { /** The fungible token asset identifier (the `ft_balances.token` column). */ token: string; diff --git a/tests/api/pox5/signers.test.ts b/tests/api/pox5/signers.test.ts new file mode 100644 index 000000000..3945d3390 --- /dev/null +++ b/tests/api/pox5/signers.test.ts @@ -0,0 +1,241 @@ +import supertest from 'supertest'; +import { afterEach, beforeEach, describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { STACKS_TESTNET } from '@stacks/network'; +import { Pox5EventName } from '@stacks/codec'; +import { ApiServer, startApiServer } from '../../../src/api/init.ts'; +import { PgWriteStore } from '../../../src/datastore/pg-write-store.ts'; +import { migrate } from '../../test-helpers.ts'; +import { TestBlockBuilder } from '../test-builders.ts'; + +/** + * pox-5 `register-signer` → materialized `staking_signers` registry, surfaced at + * GET /extended/v3/staking/signers. The contract keys its `signers` map by the + * signer principal and overwrites on re-registration (latest-wins). + */ + +const SIGNER_A = 'ST3NBRSFKX28FQ2ZJ1MAKX58HKHSDGNV5N7R21XCP.signer-manager'; +const SIGNER_B = 'ST1SJ3DTE5DN7X54YDH5D64R3BCB6A2AG2ZQ8YPD5.signer-manager'; +const KEY1 = '0x' + '02'.repeat(33); +const KEY2 = '0x' + '03'.repeat(33); +const KEY_B = '0x' + '04'.repeat(33); + +interface StakingSignerItem { + signer: string; + signer_key: string; + tx_id: string; + block_height: number; + burn_block_height: number; +} +interface SignersPage { + total: number; + limit: number; + cursor: { next: string | null; previous: string | null; current: string | null }; + results: StakingSignerItem[]; +} + +describe('pox-5 staking signers', () => { + let db: PgWriteStore; + let api: ApiServer; + + async function getSigners(query: Record = {}): Promise { + const res = await supertest(api.server).get('/extended/v3/staking/signers').query(query); + assert.equal(res.status, 200, res.text); + return JSON.parse(res.text) as SignersPage; + } + function registerSignerBlock(args: { + block_height: number; + block_hash: string; + index_block_hash: string; + parent_block_hash?: string; + parent_index_block_hash?: string; + tx_id: string; + signer: string; + signer_key: string; + }) { + return new TestBlockBuilder(args) + .addTx({ tx_id: args.tx_id }) + .addTxPox5Event({ + name: Pox5EventName.RegisterSigner, + data: { signer: args.signer, signer_key: args.signer_key }, + }) + .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 }); + }); + + afterEach(async () => { + await api.terminate(); + await db?.close(); + await migrate('down'); + }); + + test('a register-signer event appears in GET /staking/signers', async () => { + await db.update( + registerSignerBlock({ + block_height: 1, + block_hash: '0x01', + index_block_hash: '0x01', + tx_id: '0x' + 'a1'.repeat(32), + signer: SIGNER_A, + signer_key: KEY1, + }) + ); + const page = await getSigners(); + assert.equal(page.total, 1); + assert.equal(page.results.length, 1); + assert.equal(page.results[0].signer, SIGNER_A); + assert.equal(page.results[0].signer_key, KEY1); + assert.equal(page.results[0].block_height, 1); + }); + + test('re-registration rotates the key (latest-wins, one row per signer)', async () => { + await db.update( + registerSignerBlock({ + block_height: 1, + block_hash: '0x01', + index_block_hash: '0x01', + tx_id: '0x' + 'a1'.repeat(32), + signer: SIGNER_A, + signer_key: KEY1, + }) + ); + await db.update( + registerSignerBlock({ + block_height: 2, + block_hash: '0x02', + index_block_hash: '0x02', + parent_block_hash: '0x01', + parent_index_block_hash: '0x01', + tx_id: '0x' + 'a2'.repeat(32), + signer: SIGNER_A, + signer_key: KEY2, + }) + ); + const page = await getSigners(); + assert.equal(page.total, 1, 'still a single signer row'); + assert.equal(page.results[0].signer_key, KEY2, 'key rotated to the latest registration'); + }); + + test('paginates signers by signer principal', async () => { + // SIGNER_A (ST3N…) sorts after SIGNER_B (ST1S…); ordering is by signer ASC. + await db.update( + new TestBlockBuilder({ block_height: 1, block_hash: '0x01', index_block_hash: '0x01' }) + .addTx({ tx_id: '0x' + 'a1'.repeat(32) }) + .addTxPox5Event({ + name: Pox5EventName.RegisterSigner, + data: { signer: SIGNER_A, signer_key: KEY1 }, + }) + .addTxPox5Event({ + name: Pox5EventName.RegisterSigner, + data: { signer: SIGNER_B, signer_key: KEY_B }, + }) + .build() + ); + const page1 = await getSigners({ limit: '1' }); + assert.equal(page1.total, 2); + assert.equal(page1.results.length, 1); + assert.equal(page1.results[0].signer, SIGNER_B); + assert.equal(page1.cursor.next, SIGNER_A); + + const page2 = await getSigners({ limit: '1', cursor: page1.cursor.next as string }); + assert.equal(page2.results[0].signer, SIGNER_A); + assert.equal(page2.cursor.next, null); + assert.equal(page2.cursor.previous, SIGNER_B); + }); + + test('orphaning the registration block removes the signer', async () => { + await db.update( + new TestBlockBuilder({ block_height: 1, block_hash: '0x01', index_block_hash: '0x01' }).build() + ); + await db.update( + registerSignerBlock({ + block_height: 2, + block_hash: '0xa2', + index_block_hash: '0xa2', + parent_block_hash: '0x01', + parent_index_block_hash: '0x01', + tx_id: '0x' + 'a2'.repeat(32), + signer: SIGNER_A, + signer_key: KEY1, + }) + ); + assert.equal((await getSigners()).total, 1); + + // Fork B branches from genesis and overtakes, orphaning the registration. + 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 getSigners()).total, 0, 'signer gone after the registration was orphaned'); + }); + + test('orphaning a re-registration reverts to the prior key', async () => { + // Block 1 (canonical seed): SIGNER_A registers KEY1. + await db.update( + registerSignerBlock({ + block_height: 1, + block_hash: '0x01', + index_block_hash: '0x01', + tx_id: '0x' + 'a1'.repeat(32), + signer: SIGNER_A, + signer_key: KEY1, + }) + ); + // Fork A block 2: rotate to KEY2. + await db.update( + registerSignerBlock({ + block_height: 2, + block_hash: '0xa2', + index_block_hash: '0xa2', + parent_block_hash: '0x01', + parent_index_block_hash: '0x01', + tx_id: '0x' + 'a2'.repeat(32), + signer: SIGNER_A, + signer_key: KEY2, + }) + ); + assert.equal((await getSigners()).results[0].signer_key, KEY2); + + // Fork B (blocks 2 + 3) overtakes, orphaning the KEY2 rotation only. + 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 page = await getSigners(); + assert.equal(page.total, 1, 'signer still registered'); + assert.equal(page.results[0].signer_key, KEY1, 'reverted to the prior canonical key'); + }); +}); From 724ad529cc77389170e3e6551789833b2e6bd641 Mon Sep 17 00:00:00 2001 From: Rafa Cardenas <253999660+rafa-stacks@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:29:25 -0600 Subject: [PATCH 2/3] signer principal endpoint --- src/api/routes/v3/staking-signers.ts | 40 +++++++++++++++++-- .../schemas/v3/entities/staking-signers.ts | 23 ++++++++++- src/api/serializers/v3/signers.ts | 32 +++++++++++++++ src/datastore/v3/pg-store-v3.ts | 27 +++++++++++++ src/datastore/v3/types.ts | 9 +++++ tests/api/pox5/signers.test.ts | 32 +++++++++++++++ 6 files changed, 159 insertions(+), 4 deletions(-) create mode 100644 src/api/serializers/v3/signers.ts diff --git a/src/api/routes/v3/staking-signers.ts b/src/api/routes/v3/staking-signers.ts index a5e650f15..0e3a6ac87 100644 --- a/src/api/routes/v3/staking-signers.ts +++ b/src/api/routes/v3/staking-signers.ts @@ -1,6 +1,6 @@ import { FastifyPluginAsync } from 'fastify'; import { Server } from 'node:http'; -import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; +import { Type, TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; import { handleChainTipCache } from '../../controllers/cache-controller.js'; import { getPagingQueryLimit, ResourceType } from '../../pagination.js'; import { @@ -8,7 +8,16 @@ import { CursorPaginationQuerystring, SignerCursorSchema, } from '../../schemas/v3/cursors.js'; -import { StakingSignerSchema } from '../../schemas/v3/entities/staking-signers.js'; +import { + StakingSignerDetailSchema, + StakingSignerSchema, +} from '../../schemas/v3/entities/staking-signers.js'; +import { PrincipalSchema } from '../../schemas/v3/entities/common.js'; +import { + serializeDbStakingSigner, + serializeDbStakingSignerDetail, +} from '../../serializers/v3/signers.js'; +import { NotFoundError } from '../../../errors.js'; export const StakingSignersRoutes: FastifyPluginAsync< Record, @@ -47,10 +56,35 @@ export const StakingSignersRoutes: FastifyPluginAsync< previous: results.prev_cursor, current: results.current_cursor, }, - results: results.results, + results: results.results.map(serializeDbStakingSigner), }); } ); + fastify.get( + '/staking/signers/:principal', + { + preHandler: handleChainTipCache, + schema: { + operationId: 'get_staking_signer', + summary: 'Get staking signer', + description: + 'Get a registered pox-5 staking signer along with the details of the transaction that registered its current key.', + tags: ['Staking'], + params: Type.Object({ principal: PrincipalSchema }), + response: { + 200: StakingSignerDetailSchema, + }, + }, + }, + async (req, reply) => { + const signer = await fastify.db.v3.getStakingSigner({ signer: req.params.principal }); + if (!signer) { + throw new NotFoundError('Staking signer not found'); + } + await reply.send(serializeDbStakingSignerDetail(signer)); + } + ); + await Promise.resolve(); }; diff --git a/src/api/schemas/v3/entities/staking-signers.ts b/src/api/schemas/v3/entities/staking-signers.ts index 373c7ff69..80eb5ccb9 100644 --- a/src/api/schemas/v3/entities/staking-signers.ts +++ b/src/api/schemas/v3/entities/staking-signers.ts @@ -1,5 +1,10 @@ import { Static, Type } from '@sinclair/typebox'; -import { PrincipalSchema } from './common.js'; +import { + BitcoinBlockPositionSchema, + BlockPositionSchema, + PrincipalSchema, + TransactionIdSchema, +} from './common.js'; export const StakingSignerSchema = Type.Object( { @@ -19,3 +24,19 @@ export const StakingSignerSchema = Type.Object( { title: 'StakingSigner' } ); export type StakingSigner = Static; + +/** A single signer with the block position of the transaction that registered its key. */ +export const StakingSignerDetailSchema = Type.Composite( + [ + StakingSignerSchema, + Type.Object({ + transaction: Type.Object({ + tx_id: TransactionIdSchema, + block: BlockPositionSchema, + bitcoin_block: BitcoinBlockPositionSchema, + }), + }), + ], + { title: 'StakingSignerDetail' } +); +export type StakingSignerDetail = Static; diff --git a/src/api/serializers/v3/signers.ts b/src/api/serializers/v3/signers.ts new file mode 100644 index 000000000..083d6d501 --- /dev/null +++ b/src/api/serializers/v3/signers.ts @@ -0,0 +1,32 @@ +import { DbStakingSigner, DbStakingSignerDetail } from '../../../datastore/v3/types.js'; +import { StakingSigner, StakingSignerDetail } from '../../schemas/v3/entities/staking-signers.js'; + +export function serializeDbStakingSigner(signer: DbStakingSigner): StakingSigner { + return { + signer: signer.signer, + signer_key: signer.signer_key, + tx_id: signer.tx_id, + block_height: signer.block_height, + burn_block_height: signer.burn_block_height, + }; +} + +export function serializeDbStakingSignerDetail(signer: DbStakingSignerDetail): StakingSignerDetail { + return { + ...serializeDbStakingSigner(signer), + transaction: { + tx_id: signer.tx_id, + block: { + height: signer.block_height, + hash: signer.block_hash, + index_hash: signer.index_block_hash, + time: signer.block_time, + tx_index: signer.tx_index, + }, + bitcoin_block: { + height: signer.burn_block_height, + time: signer.burn_block_time, + }, + }, + }; +} diff --git a/src/datastore/v3/pg-store-v3.ts b/src/datastore/v3/pg-store-v3.ts index deb80f94c..721a8273f 100644 --- a/src/datastore/v3/pg-store-v3.ts +++ b/src/datastore/v3/pg-store-v3.ts @@ -14,6 +14,7 @@ import { DbPrincipalStakingSummary, DbPrincipalTransactionSummary, DbStakingSigner, + DbStakingSignerDetail, DbTransaction, DbTransactionCursor, DbTransactionEvent, @@ -1345,4 +1346,30 @@ export class PgStoreV3 extends BasePgStoreModule { }; }); } + + /** + * Gets a single registered pox-5 staking signer by its principal, joined with + * the block position of its registration transaction. + * @param args - The arguments for the query. + * @returns The signer, or null if not registered. + */ + async getStakingSigner(args: { signer: Principal }): Promise { + return await this.sqlTransaction(async sql => { + const [result] = await sql` + SELECT + ${sql(prefixedCols(STAKING_SIGNER_COLUMNS, 's'))}, + t.block_hash, + t.index_block_hash, + t.block_time, + t.tx_index, + t.burn_block_time + FROM staking_signers s + INNER JOIN txs t + ON t.tx_id = s.tx_id AND t.canonical = true AND t.microblock_canonical = true + WHERE s.signer = ${args.signer} + LIMIT 1 + `; + return result ?? null; + }); + } } diff --git a/src/datastore/v3/types.ts b/src/datastore/v3/types.ts index 6c6bd41bd..8d2f05ecc 100644 --- a/src/datastore/v3/types.ts +++ b/src/datastore/v3/types.ts @@ -235,6 +235,15 @@ export interface DbStakingSigner { burn_block_height: number; } +/** A signer joined with the block position of its registration transaction. */ +export interface DbStakingSignerDetail extends DbStakingSigner { + block_hash: string; + index_block_hash: string; + block_time: number; + tx_index: number; + burn_block_time: number; +} + export interface DbPrincipalFtBalance { /** The fungible token asset identifier (the `ft_balances.token` column). */ token: string; diff --git a/tests/api/pox5/signers.test.ts b/tests/api/pox5/signers.test.ts index 3945d3390..c795d3059 100644 --- a/tests/api/pox5/signers.test.ts +++ b/tests/api/pox5/signers.test.ts @@ -238,4 +238,36 @@ describe('pox-5 staking signers', () => { assert.equal(page.total, 1, 'signer still registered'); assert.equal(page.results[0].signer_key, KEY1, 'reverted to the prior canonical key'); }); + + test('GET /staking/signers/:principal returns the signer with registration transaction details', async () => { + const txId = '0x' + 'a1'.repeat(32); + await db.update( + registerSignerBlock({ + block_height: 1, + block_hash: '0x01', + index_block_hash: '0x01', + tx_id: txId, + signer: SIGNER_A, + signer_key: KEY1, + }) + ); + const res = await supertest(api.server).get(`/extended/v3/staking/signers/${SIGNER_A}`); + assert.equal(res.status, 200, res.text); + const body = JSON.parse(res.text); + assert.equal(body.signer, SIGNER_A); + assert.equal(body.signer_key, KEY1); + // The registration transaction's block position is joined in from `txs`. + assert.equal(body.transaction.tx_id, txId); + assert.equal(body.transaction.block.height, 1); + assert.equal(body.transaction.block.index_hash, '0x01'); + assert.equal(typeof body.transaction.block.hash, 'string'); + assert.equal(typeof body.transaction.block.tx_index, 'number'); + assert.equal(typeof body.transaction.bitcoin_block.height, 'number'); + assert.equal(typeof body.transaction.bitcoin_block.time, 'number'); + }); + + test('GET /staking/signers/:principal returns 404 for an unregistered principal', async () => { + const res = await supertest(api.server).get(`/extended/v3/staking/signers/${SIGNER_A}`); + assert.equal(res.status, 404); + }); }); From e5a375fd0ae1deeb37f04d11d062717a54808efb Mon Sep 17 00:00:00 2001 From: Rafa Cardenas <253999660+rafa-stacks@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:48:00 -0600 Subject: [PATCH 3/3] simplify summary --- src/api/schemas/v3/entities/staking-signers.ts | 7 ------- src/api/serializers/v3/signers.ts | 3 --- tests/api/pox5/signers.test.ts | 4 ---- 3 files changed, 14 deletions(-) diff --git a/src/api/schemas/v3/entities/staking-signers.ts b/src/api/schemas/v3/entities/staking-signers.ts index 80eb5ccb9..9c673dec2 100644 --- a/src/api/schemas/v3/entities/staking-signers.ts +++ b/src/api/schemas/v3/entities/staking-signers.ts @@ -13,13 +13,6 @@ export const StakingSignerSchema = Type.Object( description: 'The registered compressed secp256k1 public key, as a `0x`-prefixed hex string', examples: ['0x03a0f9e1...'], }), - tx_id: Type.String({ description: 'The transaction that registered this signer key' }), - block_height: Type.Integer({ - description: 'The Stacks block height at which the signer key was registered', - }), - burn_block_height: Type.Integer({ - description: 'The burnchain block height at which the signer key was registered', - }), }, { title: 'StakingSigner' } ); diff --git a/src/api/serializers/v3/signers.ts b/src/api/serializers/v3/signers.ts index 083d6d501..56b96da5e 100644 --- a/src/api/serializers/v3/signers.ts +++ b/src/api/serializers/v3/signers.ts @@ -5,9 +5,6 @@ export function serializeDbStakingSigner(signer: DbStakingSigner): StakingSigner return { signer: signer.signer, signer_key: signer.signer_key, - tx_id: signer.tx_id, - block_height: signer.block_height, - burn_block_height: signer.burn_block_height, }; } diff --git a/tests/api/pox5/signers.test.ts b/tests/api/pox5/signers.test.ts index c795d3059..2c0dc4e3b 100644 --- a/tests/api/pox5/signers.test.ts +++ b/tests/api/pox5/signers.test.ts @@ -23,9 +23,6 @@ const KEY_B = '0x' + '04'.repeat(33); interface StakingSignerItem { signer: string; signer_key: string; - tx_id: string; - block_height: number; - burn_block_height: number; } interface SignersPage { total: number; @@ -90,7 +87,6 @@ describe('pox-5 staking signers', () => { assert.equal(page.results.length, 1); assert.equal(page.results[0].signer, SIGNER_A); assert.equal(page.results[0].signer_key, KEY1); - assert.equal(page.results[0].block_height, 1); }); test('re-registration rotates the key (latest-wins, one row per signer)', async () => {