Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions migrations/1779800000006_staking-signers.ts
Original file line number Diff line number Diff line change
@@ -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');
}
2 changes: 2 additions & 0 deletions src/api/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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' }
Expand Down
90 changes: 90 additions & 0 deletions src/api/routes/v3/staking-signers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { FastifyPluginAsync } from 'fastify';
import { Server } from 'node:http';
import { Type, TypeBoxTypeProvider } from '@fastify/type-provider-typebox';
import { handleChainTipCache } from '../../controllers/cache-controller.js';
import { getPagingQueryLimit, ResourceType } from '../../pagination.js';
import {
CursorPaginatedResponse,
CursorPaginationQuerystring,
SignerCursorSchema,
} from '../../schemas/v3/cursors.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<never, never>,
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.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();
};
7 changes: 7 additions & 0 deletions src/api/schemas/v3/cursors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof NftBalanceCursorSchema>;

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<typeof SignerCursorSchema>;
35 changes: 35 additions & 0 deletions src/api/schemas/v3/entities/staking-signers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Static, Type } from '@sinclair/typebox';
import {
BitcoinBlockPositionSchema,
BlockPositionSchema,
PrincipalSchema,
TransactionIdSchema,
} 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...'],
}),
},
{ title: 'StakingSigner' }
);
export type StakingSigner = Static<typeof StakingSignerSchema>;

/** 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<typeof StakingSignerDetailSchema>;
29 changes: 29 additions & 0 deletions src/api/serializers/v3/signers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
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,
};
}

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,
},
},
};
}
74 changes: 71 additions & 3 deletions src/datastore/pg-write-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@
Pox5EventClaimStakerRewardsForSigner,
Pox5EventName,
Pox5EventRegisterForBond,
Pox5EventRegisterSigner,
Pox5EventSetupBond,
Pox5EventStake,
Pox5EventStakeUpdate,
Expand Down Expand Up @@ -602,12 +603,14 @@
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:
case Pox5EventName.RevokeSignerGrant:
case Pox5EventName.SetBondAdmin:
// TODO: Implement

Check warning on line 613 in src/datastore/pg-write-store.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected 'todo' comment: 'TODO: Implement'
break;
}
}
Expand Down Expand Up @@ -1135,6 +1138,70 @@
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.
Expand Down Expand Up @@ -4817,10 +4884,11 @@

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;
}
Expand Down
8 changes: 8 additions & 0 deletions src/datastore/v3/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
];
Loading
Loading