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
34 changes: 34 additions & 0 deletions src/database/migrations/012_slashing_penalty_consistency.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
-- @up
CREATE TABLE validator_registry (
validator_id TEXT PRIMARY KEY,
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE VIEW active_validators AS
SELECT validator_id, created_at, updated_at
FROM validator_registry
WHERE active = TRUE;

CREATE TABLE slashing_events (
event_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
validator_id TEXT NOT NULL CHECK (validator_id <> ''),
misbehavior_type TEXT NOT NULL CHECK (misbehavior_type <> ''),
penalty_amount NUMERIC NOT NULL CHECK (penalty_amount > 0),
base_penalty NUMERIC NOT NULL CHECK (base_penalty > 0),
total_validator_count BIGINT NOT NULL CHECK (total_validator_count > 0),
validator_count_at_slashing BIGINT NOT NULL CHECK (
validator_count_at_slashing >= 0
AND validator_count_at_slashing <= total_validator_count
),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX slashing_events_validator_created_idx
ON slashing_events (validator_id, created_at DESC);

-- @down
DROP TABLE IF EXISTS slashing_events;
DROP VIEW IF EXISTS active_validators;
DROP TABLE IF EXISTS validator_registry;
158 changes: 158 additions & 0 deletions src/slashing/executor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import {
BASE_SLASHING_PENALTY,
calculatePenalty,
type PenaltyCalculation,
} from './penaltyCalculator';

export interface SlashingQueryResult<Row = unknown> {
rows: Row[];
}

export interface SlashingDatabaseClient {
query<Row = unknown>(sql: string, params?: unknown[]): Promise<SlashingQueryResult<Row>>;
release(): void;
}

export interface SlashingDatabasePool {
connect(): Promise<SlashingDatabaseClient>;
}

export interface ExecuteSlashingInput {
validatorId: string;
misbehaviorType: string;
totalValidators: number;
}

export interface SlashingEvent extends PenaltyCalculation {
eventId: string;
validatorId: string;
misbehaviorType: string;
validatorCountAtSlashing: number;
createdAt: Date;
}

interface CountRow {
count: string | number | bigint;
}

interface SlashingEventRow {
event_id: string | number | bigint;
validator_id: string;
misbehavior_type: string;
penalty_amount: string | number;
base_penalty: string | number;
total_validator_count: string | number | bigint;
validator_count_at_slashing: string | number | bigint;
created_at: Date;
}

function requireText(value: string, name: string): void {
if (typeof value !== 'string' || value.trim().length === 0) {
throw new TypeError(`${name} must be a non-empty string`);
}
}

function parseActiveCount(row: CountRow | undefined): number {
if (!row) {
throw new Error('active validator count query returned no row');
}
const count = Number(row.count);
if (!Number.isSafeInteger(count) || count < 0) {
throw new RangeError('database returned an invalid active validator count');
}
return count;
}

/**
* Atomically snapshots active membership, calculates the penalty, and records
* the immutable event. No RPC or other external work occurs in this class.
*/
export class SlashingExecutor {
constructor(private readonly pool: SlashingDatabasePool) {}

async execute(input: ExecuteSlashingInput): Promise<SlashingEvent> {
requireText(input.validatorId, 'validatorId');
requireText(input.misbehaviorType, 'misbehaviorType');

const client = await this.pool.connect();
let transactionStarted = false;
try {
await client.query('BEGIN ISOLATION LEVEL SERIALIZABLE');
transactionStarted = true;
await client.query('LOCK TABLE validator_registry IN SHARE ROW EXCLUSIVE MODE');

const countResult = await client.query<CountRow>(
'SELECT COUNT(*) AS count FROM active_validators',
);
const validatorCountAtSlashing = parseActiveCount(countResult.rows[0]);
const calculation = calculatePenalty({
activeValidators: validatorCountAtSlashing,
totalValidators: input.totalValidators,
basePenalty: BASE_SLASHING_PENALTY,
});

const eventResult = await client.query<SlashingEventRow>(
`INSERT INTO slashing_events (
validator_id,
misbehavior_type,
penalty_amount,
base_penalty,
total_validator_count,
validator_count_at_slashing
) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING event_id, validator_id, misbehavior_type, penalty_amount,
base_penalty, total_validator_count,
validator_count_at_slashing, created_at`,
[
input.validatorId,
input.misbehaviorType,
calculation.penalty,
calculation.basePenalty,
calculation.totalValidators,
validatorCountAtSlashing,
],
);
if (!eventResult.rows[0]) {
throw new Error('slashing event insert returned no row');
}

await client.query('COMMIT');
transactionStarted = false;
return this.mapEvent(eventResult.rows[0], calculation);
} catch (error) {
if (transactionStarted) {
try {
await client.query('ROLLBACK');
} catch {
// Preserve the original transaction/commit failure.
}
}
throw error;
} finally {
client.release();
}
}

async slash(input: ExecuteSlashingInput): Promise<SlashingEvent> {
return this.execute(input);
}

async executeSlashing(input: ExecuteSlashingInput): Promise<SlashingEvent> {
return this.execute(input);
}

private mapEvent(row: SlashingEventRow, calculation: PenaltyCalculation): SlashingEvent {
return {
eventId: String(row.event_id),
validatorId: row.validator_id,
misbehaviorType: row.misbehavior_type,
penalty: Number(row.penalty_amount),
multiplier: calculation.multiplier,
activeValidators: Number(row.validator_count_at_slashing),
totalValidators: Number(row.total_validator_count),
basePenalty: Number(row.base_penalty),
validatorCountAtSlashing: Number(row.validator_count_at_slashing),
createdAt: row.created_at,
};
}
}
80 changes: 80 additions & 0 deletions src/slashing/penaltyCalculator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
export const BASE_SLASHING_PENALTY = 500;

export interface PenaltyInputs {
activeValidators: number;
totalValidators: number;
basePenalty?: number;
}

export interface PenaltyCalculation {
penalty: number;
multiplier: number;
activeValidators: number;
totalValidators: number;
basePenalty: number;
}

function assertValidatorCount(value: number, name: string, allowZero: boolean): void {
if (!Number.isFinite(value) || !Number.isSafeInteger(value)) {
throw new RangeError(`${name} must be a finite, safe integer`);
}
if (allowZero ? value < 0 : value <= 0) {
throw new RangeError(`${name} must be ${allowZero ? 'non-negative' : 'greater than zero'}`);
}
}

/**
* Calculate the monetary penalty from the validator-set snapshot.
*
* The positional form is (activeValidators, totalValidators, basePenalty?).
* The object form is also supported to keep call sites self-documenting.
*/
export function calculatePenalty(inputs: PenaltyInputs): PenaltyCalculation;
export function calculatePenalty(
activeValidators: number,
totalValidators: number,
basePenalty?: number,
): PenaltyCalculation;
export function calculatePenalty(
inputsOrActive: PenaltyInputs | number,
positionalTotal?: number,
positionalBase: number = BASE_SLASHING_PENALTY,
): PenaltyCalculation {
const inputs: PenaltyInputs =
typeof inputsOrActive === 'number'
? {
activeValidators: inputsOrActive,
totalValidators: positionalTotal as number,
basePenalty: positionalBase,
}
: inputsOrActive;

const {
activeValidators,
totalValidators,
basePenalty = BASE_SLASHING_PENALTY,
} = inputs;

assertValidatorCount(totalValidators, 'totalValidators', false);
assertValidatorCount(activeValidators, 'activeValidators', true);
if (activeValidators > totalValidators) {
throw new RangeError('activeValidators must not exceed totalValidators');
}
if (!Number.isFinite(basePenalty) || basePenalty <= 0) {
throw new RangeError('basePenalty must be finite and greater than zero');
}

const multiplier = 1 + (totalValidators - activeValidators) / totalValidators;
const penalty = basePenalty * multiplier;
if (!Number.isFinite(penalty)) {
throw new RangeError('calculated penalty must be finite');
}

return {
penalty,
multiplier,
activeValidators,
totalValidators,
basePenalty,
};
}
117 changes: 117 additions & 0 deletions src/staking/validatorRegistry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
export interface ValidatorMembership {
validatorId: string;
active: boolean;
createdAt: Date;
updatedAt: Date;
}

export interface ValidatorRegistryQueryResult<Row = unknown> {
rows: Row[];
}

export interface ValidatorRegistryDatabase {
query<Row = unknown>(sql: string, params?: unknown[]): Promise<ValidatorRegistryQueryResult<Row>>;
}

interface ValidatorMembershipRow {
validator_id: string;
active: boolean;
created_at: Date;
updated_at: Date;
}

interface CountRow {
count: string | number | bigint;
}

function requireValidatorId(validatorId: string): void {
if (typeof validatorId !== 'string' || validatorId.trim().length === 0) {
throw new TypeError('validatorId must be a non-empty string');
}
}

function mapMembership(row: ValidatorMembershipRow): ValidatorMembership {
return {
validatorId: row.validator_id,
active: row.active,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}

function parseCount(value: string | number | bigint): number {
const count = Number(value);
if (!Number.isSafeInteger(count) || count < 0) {
throw new RangeError('database returned an invalid validator count');
}
return count;
}

/** PostgreSQL-backed authority for validator active-set membership. */
export class ValidatorRegistry {
constructor(private readonly database: ValidatorRegistryDatabase) {}

async registerValidator(validatorId: string, active: boolean = true): Promise<ValidatorMembership> {
requireValidatorId(validatorId);
if (typeof active !== 'boolean') {
throw new TypeError('active must be a boolean');
}

const result = await this.database.query<ValidatorMembershipRow>(
`INSERT INTO validator_registry (validator_id, active)
VALUES ($1, $2)
ON CONFLICT (validator_id) DO UPDATE
SET active = EXCLUDED.active, updated_at = NOW()
RETURNING validator_id, active, created_at, updated_at`,
[validatorId, active],
);
return mapMembership(result.rows[0]);
}

async addValidator(validatorId: string, active: boolean = true): Promise<ValidatorMembership> {
return this.registerValidator(validatorId, active);
}

async activateValidator(validatorId: string): Promise<ValidatorMembership> {
return this.setActive(validatorId, true);
}

async deactivateValidator(validatorId: string): Promise<ValidatorMembership> {
return this.setActive(validatorId, false);
}

/** Removing a validator means removing it from the active set, not erasing its identity. */
async removeValidator(validatorId: string): Promise<ValidatorMembership> {
return this.deactivateValidator(validatorId);
}

async getActiveValidatorIds(): Promise<string[]> {
const result = await this.database.query<{ validator_id: string }>(
'SELECT validator_id FROM active_validators ORDER BY validator_id',
);
return result.rows.map((row) => row.validator_id);
}

async getActiveValidatorCount(): Promise<number> {
const result = await this.database.query<CountRow>('SELECT COUNT(*) AS count FROM active_validators');
if (!result.rows[0]) {
throw new Error('active validator count query returned no row');
}
return parseCount(result.rows[0].count);
}

private async setActive(validatorId: string, active: boolean): Promise<ValidatorMembership> {
requireValidatorId(validatorId);
const result = await this.database.query<ValidatorMembershipRow>(
`UPDATE validator_registry
SET active = $2, updated_at = NOW()
WHERE validator_id = $1
RETURNING validator_id, active, created_at, updated_at`,
[validatorId, active],
);
if (!result.rows[0]) {
throw new Error(`Validator ${validatorId} is not registered`);
}
return mapMembership(result.rows[0]);
}
}
Loading
Loading