Skip to content

Commit

Permalink
feat: get single block proposal endpoint
Browse files Browse the repository at this point in the history
  • Loading branch information
zone117x committed Nov 12, 2024
1 parent 4e657aa commit b58f164
Show file tree
Hide file tree
Showing 4 changed files with 276 additions and 26 deletions.
95 changes: 95 additions & 0 deletions src/api/routes/block-proposals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@ import { Server } from 'http';
import BigNumber from 'bignumber.js';
import { differenceInMilliseconds } from 'date-fns';
import {
BlockHashParamSchema,
BlockProposalsEntry,
BlockProposalsEntrySchema,
BlockProposalSignerData,
BlockProposalsResponseSchema,
} from '../schemas';
import { NotFoundError } from '../errors';

export const BlockProposalsRoutes: FastifyPluginCallback<
Record<never, never>,
Expand Down Expand Up @@ -106,5 +109,97 @@ export const BlockProposalsRoutes: FastifyPluginCallback<
}
);

fastify.get(
'/v1/block_proposals/:block_hash',
{
schema: {
operationId: 'getBlockProposals',
summary: 'Signer information for most recent block proposals',
description: 'Signer information for most recent block proposals',
tags: ['Blocks Proposals'],
params: Type.Object({
block_hash: BlockHashParamSchema,
}),
querystring: Type.Object({
limit: Type.Integer({
description: 'Number of results to return',
default: 25,
minimum: 1,
maximum: 50,
}),
offset: Type.Integer({
description: 'Number of results to skip',
default: 0,
}),
}),
response: {
200: BlockProposalsEntrySchema,
},
},
},
async (request, reply) => {
const result = await fastify.db.sqlTransaction(async sql => {
const results = await fastify.db.getBlockProposal({
sql,
blockHash: request.params.block_hash,
});
if (results.length === 0) {
throw new NotFoundError('Block proposal not found');
}

const r = results[0];
const signerData = r.signer_data.map(s => {
const data: BlockProposalSignerData = {
signer_key: s.signer_key,
slot_index: s.slot_index,
response: s.response,
weight: s.weight,
weight_percentage: Number(
BigNumber(s.weight).div(r.total_signer_weight).times(100).toFixed(3)
),
stacked_amount: s.stacked_amount,
version: s.version,
received_at: s.received_at ? new Date(s.received_at).toISOString() : null,
response_time_ms: s.received_at
? differenceInMilliseconds(new Date(s.received_at), r.received_at)
: null,
reason_string: s.reason_string,
reason_code: s.reason_code,
reject_code: s.reject_code,
};
return data;
});

const entry: BlockProposalsEntry = {
received_at: r.received_at.toISOString(),
block_height: r.block_height,
block_hash: r.block_hash,
index_block_hash: r.index_block_hash,
burn_block_height: r.burn_block_height,
block_time: r.block_time,
cycle_number: r.cycle_number,
status: r.status,

// cycle data
total_signer_count: r.total_signer_count,
total_signer_weight: r.total_signer_weight,
total_signer_stacked_amount: r.total_signer_stacked_amount,

accepted_count: r.accepted_count,
rejected_count: r.rejected_count,
missing_count: r.missing_count,

accepted_weight: r.accepted_weight,
rejected_weight: r.rejected_weight,
missing_weight: r.missing_weight,

signer_data: signerData,
};
return entry;
});
await reply.send(result);
}
);

done();
};
2 changes: 1 addition & 1 deletion src/api/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ export type CycleSignersResponse = Static<typeof CycleSignersResponseSchema>;
export const CycleSignerResponseSchema = Type.Composite([CycleSignerSchema]);
export type CycleSignerResponse = Static<typeof CycleSignerResponseSchema>;

const BlockHashParamSchema = Type.String({
export const BlockHashParamSchema = Type.String({
pattern: '^(0x)?[a-fA-F0-9]{64}$',
title: 'Block hash',
description: 'Block hash',
Expand Down
130 changes: 130 additions & 0 deletions src/pg/pg-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,136 @@ export class PgStore extends BasePgStore {
return result;
}

async getBlockProposal({ sql, blockHash }: { sql: PgSqlClient; blockHash: string }) {
const result = await sql<
{
// block proposal data (from block_proposals):
received_at: Date;
block_height: number;
block_hash: string;
index_block_hash: string;
burn_block_height: number;
block_time: number;
cycle_number: number;

// proposal status (from blocks table, matched using block_hash and block_height):
status: 'pending' | 'rejected' | 'accepted';

// cycle data (from reward_set_signers, matched using cycle_number AKA reward_cycle):
total_signer_count: number;
total_signer_weight: number;
total_signer_stacked_amount: string;

// aggregate signer response data (from block_responses, matched using block_hash AKA signer_sighash, where missing is detected by the absence of a block_response for a given signer_key from the reward_set_signers table):
accepted_count: number;
rejected_count: number;
missing_count: number;
accepted_weight: number;
rejected_weight: number;
missing_weight: number;

// signer responses (from block_responses, matched using block_hash AKA signer_sighash, using the signer_key from the reward_set_signers table for some of the fields):
signer_data: {
signer_key: string;
slot_index: number;
response: 'accepted' | 'rejected' | 'missing';
weight: number;
stacked_amount: string;

version: string | null; // null for missing responses
received_at: string | null; // null for missing responses

// rejected fields (null for accepted and missing responses):
reason_string: string | null;
reason_code: string | null;
reject_code: string | null;
}[];
}[]
>`
SELECT
bp.received_at,
bp.block_height,
bp.block_hash,
bp.index_block_hash,
bp.burn_block_height,
EXTRACT(EPOCH FROM bp.block_time)::integer AS block_time,
bp.reward_cycle AS cycle_number,
-- Proposal status
CASE
WHEN bp.block_height > ct.block_height THEN 'pending'
WHEN b.block_hash IS NULL THEN 'rejected'
WHEN b.block_hash = bp.block_hash THEN 'accepted'
ELSE 'rejected'
END AS status,
-- Aggregate cycle data from reward_set_signers
COUNT(DISTINCT rss.signer_key)::integer AS total_signer_count,
SUM(rss.signer_weight)::integer AS total_signer_weight,
SUM(rss.signer_stacked_amount) AS total_signer_stacked_amount,
-- Aggregate response data for accepted, rejected, and missing counts and weights
COUNT(br.accepted) FILTER (WHERE br.accepted = TRUE)::integer AS accepted_count,
COUNT(br.accepted) FILTER (WHERE br.accepted = FALSE)::integer AS rejected_count,
COUNT(*) FILTER (WHERE br.id IS NULL)::integer AS missing_count,
COALESCE(SUM(rss.signer_weight) FILTER (WHERE br.accepted = TRUE), 0)::integer AS accepted_weight,
COALESCE(SUM(rss.signer_weight) FILTER (WHERE br.accepted = FALSE), 0)::integer AS rejected_weight,
COALESCE(SUM(rss.signer_weight) FILTER (WHERE br.id IS NULL), 0)::integer AS missing_weight,
-- Array of signer response details
COALESCE(
JSON_AGG(
json_build_object(
'signer_key', '0x' || encode(rss.signer_key, 'hex'),
'slot_index', rss.slot_index,
'response',
CASE
WHEN br.id IS NULL THEN 'missing'
WHEN br.accepted = TRUE THEN 'accepted'
ELSE 'rejected'
END,
'version', br.metadata_server_version,
'weight', rss.signer_weight,
'stacked_amount', rss.signer_stacked_amount::text,
'received_at', to_char(br.received_at, 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'),
'reason_string', br.reason_string,
'reason_code', br.reason_code,
'reject_code', br.reject_code
) ORDER BY rss.slot_index
),
'[]'::json
) AS signer_data
FROM block_proposals bp
-- Join with chain_tip to get the current block height
CROSS JOIN chain_tip ct
-- Join with blocks to check if there's a matching block for the same block_height and block_hash
LEFT JOIN blocks b
ON b.block_height = bp.block_height
LEFT JOIN reward_set_signers rss
ON rss.cycle_number = bp.reward_cycle
LEFT JOIN block_responses br
ON br.signer_sighash = bp.block_hash
AND br.signer_key = rss.signer_key
-- Filter for a specific block proposal based on block_hash
WHERE bp.block_hash = ${blockHash}
GROUP BY
bp.id,
ct.block_height,
b.block_hash
LIMIT 1
`;
return result;
}

async getSignerDataForRecentBlocks({
sql,
limit,
Expand Down
75 changes: 50 additions & 25 deletions tests/db/endpoints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ describe('Endpoint tests', () => {
});
});

test('get latest blocks proposals', async () => {
test('get blocks proposals', async () => {
const responseTest = await supertest(apiServer.server)
.get('/signer-metrics/v1/block_proposals?limit=50')
.expect(200);
Expand All @@ -101,6 +101,21 @@ describe('Endpoint tests', () => {

const rejectedBlockHash = '0x91b01811fdfddb38886412509fc1e6d48c91b3f4406b32b887ec261e6312ee6b';
const rejectedBlock = body.results.find(r => r.block_hash === rejectedBlockHash);
const expectedRejectedSignerData: BlockProposalSignerData = {
signer_key: '0x02e8620935d58ebffa23c260f6917cbd0915ea17d7a46df17e131540237d335504',
slot_index: 3,
response: 'rejected',
weight: 38,
weight_percentage: 76,
stacked_amount: '250000000000000',
version:
'stacks-signer signer-3.0.0.0.0.1 (release/signer-3.0.0.0.0.1:b26f406, release build, linux [x86_64])',
received_at: '2024-11-02T13:27:31.613Z',
response_time_ms: 10874,
reason_string: 'The block was rejected due to a mismatch with expected sortition view.',
reason_code: 'SORTITION_VIEW_MISMATCH',
reject_code: null,
};
const expectedRejectedBlockData: BlockProposalsEntry = {
received_at: '2024-11-02T13:27:20.739Z',
block_height: 112267,
Expand All @@ -119,34 +134,26 @@ describe('Endpoint tests', () => {
accepted_weight: 1,
rejected_weight: 46,
missing_weight: 3,
signer_data: expect.any(Array),
signer_data: expect.arrayContaining([expectedRejectedSignerData]),
};
expect(rejectedBlock).toEqual(expectedRejectedBlockData);

const rejectedBlockSignerKey =
'0x02e8620935d58ebffa23c260f6917cbd0915ea17d7a46df17e131540237d335504';
const rejectedSigner = rejectedBlock!.signer_data.find(
s => s.signer_key === rejectedBlockSignerKey
);
const expectedRejectedSignerData: BlockProposalSignerData = {
signer_key: '0x02e8620935d58ebffa23c260f6917cbd0915ea17d7a46df17e131540237d335504',
slot_index: 3,
response: 'rejected',
weight: 38,
weight_percentage: 76,
stacked_amount: '250000000000000',
version:
'stacks-signer signer-3.0.0.0.0.1 (release/signer-3.0.0.0.0.1:b26f406, release build, linux [x86_64])',
received_at: '2024-11-02T13:27:31.613Z',
response_time_ms: 10874,
reason_string: 'The block was rejected due to a mismatch with expected sortition view.',
reason_code: 'SORTITION_VIEW_MISMATCH',
reject_code: null,
};
expect(rejectedSigner).toEqual(expectedRejectedSignerData);

const acceptedBlockHash = '0x2f1c4e83fda403682b1ab5dd41383e47d2cb3dfec0fd26f0886883462d7802fb';
const acceptedBlock = body.results.find(r => r.block_hash === acceptedBlockHash);
const expectedAcceptedSignerData: BlockProposalSignerData = {
signer_key: '0x02567b1f5056f6c3e59e759f66216d21239904d1cc2d847c5dcc3c2b6534d7bead',
slot_index: 0,
response: 'accepted',
weight: 1,
weight_percentage: 2,
stacked_amount: '6490000003000',
version: 'stacks-signer 0.0.1 (:, release build, linux [x86_64])',
received_at: '2024-11-02T13:32:58.090Z',
response_time_ms: 4774,
reason_string: null,
reason_code: null,
reject_code: null,
};
const expectedAcceptedBlockData: BlockProposalsEntry = {
received_at: '2024-11-02T13:32:53.316Z',
block_height: 112276,
Expand All @@ -165,9 +172,27 @@ describe('Endpoint tests', () => {
accepted_weight: 47,
rejected_weight: 0,
missing_weight: 3,
signer_data: expect.any(Array),
signer_data: expect.arrayContaining([expectedAcceptedSignerData]),
};
expect(acceptedBlock).toEqual(expectedAcceptedBlockData);

const getProposal1 = await supertest(apiServer.server)
.get(`/signer-metrics/v1/block_proposals/${rejectedBlockHash}`)
.expect(200);
const body1: BlockProposalsEntry = getProposal1.body;
expect(body1).toEqual(expectedRejectedBlockData);

const getProposal2 = await supertest(apiServer.server)
.get(`/signer-metrics/v1/block_proposals/${acceptedBlockHash}`)
.expect(200);
const body2: BlockProposalsEntry = getProposal2.body;
expect(body2).toEqual(expectedAcceptedBlockData);

await supertest(apiServer.server)
.get(
`/signer-metrics/v1/block_proposals/0x00000083fda403682b1ab5dd41383e47d2cb3dfec0fd26f0886883462d000000`
)
.expect(404);
});

test('get latest blocks', async () => {
Expand Down

0 comments on commit b58f164

Please sign in to comment.